diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json new file mode 100644 index 0000000..5512b28 --- /dev/null +++ b/.claude-plugin/plugin.json @@ -0,0 +1,27 @@ +{ + "name": "multi-media-publisher", + "version": "0.2.0", + "description": "Cross-platform content publishing orchestration: 小红书 / 微信图文 / 微信公众号文章 / X Articles / Substack. Wizard-driven manifest creation, draft-first safety, encrypted credential vault.", + "skills": ["./SKILL.md"], + "scripts": { + "publish": "scripts/mmp.py publish", + "validate": "scripts/mmp.py validate", + "setup": "scripts/mmp.py setup", + "list": "scripts/mmp.py list", + "resume": "scripts/mmp.py resume", + "doctor": "scripts/mmp.py doctor", + "wizard": "scripts/mmp.py wizard" + }, + "homepage": "https://github.com/yxliao-lewis/multi-media-publisher", + "license": "MIT", + "keywords": [ + "publishing", + "social-media", + "xiaohongshu", + "wechat", + "x", + "substack", + "claude-code", + "openclaw" + ] +} diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..b13ffbe --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,50 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + branches: [main] + +jobs: + test: + name: ${{ matrix.os }} / py${{ matrix.python-version }} + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, macos-latest] + python-version: ["3.10", "3.11", "3.12"] + + steps: + - uses: actions/checkout@v4 + + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + cache: pip + cache-dependency-path: pyproject.toml + + - name: Install + run: | + python -m pip install --upgrade pip + pip install -e ".[dev]" + + - name: Lint (ruff) + run: | + python -m ruff check . + python -m ruff format --check . + + - name: Typecheck (mypy) + run: python -m mypy core + + - name: Unit tests + run: python -m pytest -q + + - name: Smoke test + env: + # Provide ephemeral, fake home so smoke writes its vault into a + # job-local dir (the smoke script also overrides XDG_CONFIG_HOME). + HOME: ${{ runner.temp }} + run: python scripts/test_local.py diff --git a/.gitignore b/.gitignore index 8702be2..ab43b9a 100644 --- a/.gitignore +++ b/.gitignore @@ -4,3 +4,10 @@ __pycache__/ runs/ .env *.log + +# v0.2 additions +.coverage +.pytest_cache/ +.mypy_cache/ +.ruff_cache/ +*.egg-info/ diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..2b8d59b --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,55 @@ +# Changelog + +## 0.2.0 — 2026-05-06 + +Major refactor: provider abstraction + dual-host distribution + wizard. + +Real-account verified end-to-end: `wechat-article` creates real drafts +on `mp.weixin.qq.com`; `xiaohongshu` creates real local drafts via the +xhs skill's `draft.sh`. 7 integration bugs discovered + fixed during +verification. See `docs/HANDOFF.md` "Discovered during real-account +verification" for the full list. + +### Added + +- `core/` host-agnostic Python: manifest schema, provider registry, + credential vault (age-encrypted), run lifecycle, platform rules, settings, + wizard fragments +- 5 bundled providers: `wechat-article`, `xiaohongshu`, `wechat-image`, + `x-article`, `substack` +- `scripts/mmp.py` unified CLI: `validate`, `publish`, `setup`, `list`, + `resume`, `doctor`, `wizard` +- `.claude-plugin/plugin.json` for Claude Code plugin marketplace +- 3-stage conversational wizard (source extraction → target selection → + manifest assembly) + credential setup wizard +- Manifest schema v0.2: `schema_version`, full-form/short-form targets, + `defaults` block, `dry-run` mode, account override per target +- Age-encrypted vault at `~/.config/mmp/credentials.json.age` +- GitHub Actions CI matrix (macOS + ubuntu × Python 3.10/3.11/3.12) +- User-facing docs: `architecture.md`, `provider-contract.md`, + `credentials.md`, `safety-policy.md`, `manual-verification.md` + +### Changed + +- Default `mode` is `draft`; `publish` requires explicit confirmation +- Manifest fields normalized; lock-file `manifest.lock.json` written per run +- `runs/-/` is now self-contained: manifest, lock, packs, result, + log, checkpoints, artifacts +- `mode_actual` introduces `"stub"` value to distinguish connector-not-implemented + draft fallbacks (x-article, substack) from real dry-run + +### Deprecated + +- `scripts/prepare_image_post.py`, `scripts/prepare_longform.py`, + `scripts/execute_image_post.py`, `scripts/adapt_content.py`, + `scripts/publish_manifest.py`, `scripts/wechat_api_draft.py` — kept as + thin shims; removal in v0.3 + +### Removed + +- Legacy `references/*.md` (moved to `docs/` or `providers//notes.md`) + +## 0.1.0 — 2026-05 (pre-redesign) + +- Initial OpenClaw skill: prepare/execute scripts, image-post + longform + pipelines, WeChat API dry-run helper, local smoke test. diff --git a/Makefile b/Makefile index f56d0c3..519c0c9 100644 --- a/Makefile +++ b/Makefile @@ -1,6 +1,21 @@ -PYTHON ?= python3 +.PHONY: test lint typecheck unit smoke clean -.PHONY: test +test: lint typecheck unit smoke -test: - $(PYTHON) scripts/test_local.py +unit: + python3 -m pytest -q + +lint: + python3 -m ruff check . + python3 -m ruff format --check . + +typecheck: + python3 -m mypy core + +smoke: + python3 scripts/test_local.py + +clean: + rm -rf .pytest_cache .mypy_cache .ruff_cache __pycache__ + find . -name "__pycache__" -type d -exec rm -rf {} + + find . -name "*.pyc" -delete diff --git a/README.md b/README.md index a03ece1..dc6e65b 100644 --- a/README.md +++ b/README.md @@ -1,56 +1,121 @@ -# Multi-media Publisher / 多媒体发布 +# multi-media-publisher -统一调度多平台内容发布的 OpenClaw skill。 +Publish one piece of content to many platforms — 小红书, 微信图文, 微信公众号 +文章, X Articles, Substack — through one manifest, with draft-first safety +and an encrypted credential vault. -## MVP 目标 +Works as a Claude Code plugin **and** an OpenClaw skill from the same source. -- 图文内容:小红书 + 微信图文内容 -- 长文章:微信公众号文章 + X Articles + Substack 草稿 -- 未来:视频号 / 小红书视频 / 抖音 / B站 / YouTube Shorts +## Status -## 当前状态 +v0.2 — provider abstraction + 5 first-party providers + conversational +manifest wizard + age-encrypted vault. See [`docs/HANDOFF.md`](docs/HANDOFF.md). -已完成: +## Install -- Skill 骨架:`SKILL.md` -- Manifest schema:`references/manifest-schema.md` -- 平台矩阵:`references/platform-map.md` -- 发布安全策略:`references/publishing-policy.md` -- 候选技能调研:`references/candidate-skills.md` -- 工作流规划:`references/workflows.md` -- 示例 manifest:`examples/image-post.yaml`, `examples/longform.yaml` -- 辅助脚本: - - `scripts/publish_manifest.py`:校验 manifest 并创建 run skeleton - - `scripts/adapt_content.py`:生成各平台 content pack 草稿 - - `scripts/prepare_image_post.py`:生成小红书/微信图文 payload 和预览,不发布;同时生成 `wechat-article-api-bridge` payload,可被 `wechat_api_draft.py --dry-run` 直接验证 - - `scripts/execute_image_post.py`:执行已确认的草稿动作;当前支持小红书本地草稿 + 微信图文操作指南 - - `scripts/wechat_api_draft.py`:微信公众号 API 草稿助手,支持 dry-run;`draft-from-payload` 可读取 payload 内的 `cover` - - `scripts/prepare_longform.py`:生成公众号文章 / X Articles / Substack 长文 payload 和预览,不发布 - - `scripts/test_local.py` + `Makefile`:本地 smoke test,覆盖 py_compile、image-post prepare、微信 guide、小红书本地 draft、WeChat API dry-run、longform prepare +### As a Claude Code plugin -## 基本用法 +(Marketplace submission pending — for now, clone the repo and point Claude +Code at the directory.) ```bash -python3 skills/multi-media-publisher/scripts/publish_manifest.py \ - skills/multi-media-publisher/examples/image-post.yaml +git clone https://github.com/yxliao-lewis/multi-media-publisher.git +# Then in Claude Code: settings → plugins → load from directory +``` + +After v0.2.0 marketplace submission lands, the install will be: + +``` +/plugin install multi-media-publisher +``` + +### As an OpenClaw skill + +Clone into your skills directory: + +```bash +git clone https://github.com/yxliao-lewis/multi-media-publisher.git \ + ~/.openclaw/skills/multi-media-publisher +``` + +### Python deps + +```bash +pip install -e ".[dev]" +``` + +Requires Python 3.10+. + +## Quickstart -python3 skills/multi-media-publisher/scripts/adapt_content.py \ - skills/multi-media-publisher/examples/longform.yaml \ - --out /tmp/mmp-adapt +### Conversational wizard (recommended) -python3 skills/multi-media-publisher/scripts/prepare_image_post.py \ - /path/to/image-post.yaml +In Claude Code or OpenClaw, just say what you want: -python3 skills/multi-media-publisher/scripts/execute_image_post.py \ - /path/to/run-dir --target xiaohongshu --yes-draft +> 帮我把这篇文章发到公众号、X 长文章、Substack 草稿。 -python3 skills/multi-media-publisher/scripts/wechat_api_draft.py \ - draft-from-payload /path/to/run-dir/packs/wechat-article-api-bridge/payload.json --dry-run +Claude reads `core/wizard/*.md` and walks you through source extraction → +target selection → manifest assembly → draft. -python3 skills/multi-media-publisher/scripts/prepare_longform.py \ - skills/multi-media-publisher/examples/longform.yaml +### CLI + +```bash +# Validate a manifest +mmp validate examples/longform.yaml + +# Configure credentials for a provider +mmp setup wechat-article + +# Run a publish (defaults to draft mode in the manifest) +mmp publish examples/longform.yaml + +# List providers / accounts / runs +mmp list providers +mmp list accounts +mmp list runs + +# Self-check +mmp doctor +``` + +## Safety + +- Default `mode: draft`. Public publishing requires explicit `mode: publish` + in the manifest **plus** an in-conversation confirmation. +- Credentials are stored in `~/.config/mmp/credentials.json.age` (age-encrypted). +- Secrets never appear in `result.json`, `publish-log.md`, or printed output. +- Full policy: [`docs/safety-policy.md`](docs/safety-policy.md). + +## Architecture + +- [`docs/architecture.md`](docs/architecture.md) — high-level overview +- [`docs/provider-contract.md`](docs/provider-contract.md) — write your own provider +- [`docs/credentials.md`](docs/credentials.md) — vault and ENV usage +- [`docs/manual-verification.md`](docs/manual-verification.md) — pre-release checklist +- [`docs/superpowers/specs/`](docs/superpowers/specs/) — full design spec + +## Project layout -make -C skills/multi-media-publisher test ``` +core/ # host-agnostic Python (manifest, providers, vault, runs) +providers/ # bundled first-party providers + wechat_article/ + xiaohongshu/ + wechat_image/ + x_article/ + substack/ +scripts/mmp.py # CLI entry +.claude-plugin/ # Claude Code plugin manifest +SKILL.md # OpenClaw + Claude Code skill manifest +docs/ # user-facing docs +tests/ # core tests + integration tests +``` + +## Contributing + +- New provider? Read [`docs/provider-contract.md`](docs/provider-contract.md). +- Bug or design discussion? Open an issue. + +## License -脚本不会真实发布。真实外发必须通过已验证的平台 skill,并遵守 draft-first / confirmation-first 策略。 +MIT. diff --git a/SKILL.md b/SKILL.md index 3226ee1..2e5f94e 100644 --- a/SKILL.md +++ b/SKILL.md @@ -1,92 +1,125 @@ --- name: Multi-media Publisher -# keep trigger concrete so the router loads this skill before lower-level platform skills -description: This skill should be used when the user asks to "多媒体发布", "多平台发布", "同步发布小红书和微信图文", "发微信图文和小红书", "发布长文章到公众号/X/Substack", "cross-post", "publish everywhere", or wants one content package adapted and published/drafted across Xiaohongshu, WeChat image posts, WeChat Official Account articles, X Articles/Twitter, Substack, or future video platforms. -version: 0.1.0 +description: This skill should be used when the user asks to "多媒体发布", "多平台发布", "同步发布小红书和微信图文", "发微信图文和小红书", "发布长文章到公众号/X/Substack", "cross-post", "publish everywhere", or wants one content package adapted and published/drafted across Xiaohongshu, WeChat image posts, WeChat Official Account articles, X Articles/Twitter, Substack, or future video platforms; or "新发布", "帮我发一组到", "wizard", "guide me to publish". +version: 0.2.0 --- # Multi-media Publisher / 多媒体发布 -Act as the orchestration layer for cross-platform publishing. Do not replace mature platform-specific skills; route to them with a shared manifest, consistent approval policy, and post-run logging. - -## Core Principle - -Separate content by media form first, platform second: - -1. **image-post / 图文内容** — social image feed posts, currently Xiaohongshu + WeChat image posts. Treat WeChat 图文内容 as analogous to Xiaohongshu image posts, not as WeChat Official Account long articles. -2. **longform / 长文章** — Markdown/HTML/article publishing, currently WeChat Official Account article + X Articles + Substack. -3. **video-post / 视频内容** — reserved extension point for Xiaohongshu video, WeChat Channels, Douyin, Bilibili, YouTube Shorts, etc. - -Default to **draft mode**. Any external publish/send action requires explicit user confirmation in the current conversation unless the user already gave clear, specific approval for the exact targets and content. - -## Workflow - -1. **Classify the request** - - Use `image-post` for 小红书图文, 微信图文内容, image carousel posts, social image posts. - - Use `longform` for 公众号文章, X Articles, Twitter long article, Substack, newsletter/blog essays. - - Use `video-post` only as a planned/experimental path unless a video publisher is explicitly configured. - -2. **Create or request a manifest** - - If the user provides files/content, normalize them into the manifest format in `references/manifest-schema.md`. - - If required fields are missing, ask only for the blocking field: usually body/content path, image assets, or target platforms. - - Prefer writing a manifest under `skills/multi-media-publisher/runs/-/manifest.yaml` for real runs. - -3. **Adapt content per platform** - - Consult `references/platform-map.md` for target capabilities and preferred lower-level skills. - - Preserve the user's source content. Platform-specific adaptation may change title length, hook, caption, tags, frontmatter, or CTA, but should not silently change core claims. - - Use `scripts/adapt_content.py` for deterministic manifest-to-platform pack scaffolding when useful. - -4. **Plan before external action** - - Show the target list, mode (`draft` or `publish`), and any risky assumptions. - - For draft creation, ask confirmation if it touches external services. - - For public publish, require explicit confirmation even if drafts were already approved. - -5. **Dispatch to lower-level skills/tools** - - Xiaohongshu: use the local `xiaohongshu` skill and prefer platform draft before final publish. - - WeChat image post: use `lsmonet/social-media-publish` / `social-media-publish` after installation/verification. - - WeChat Official Account article: use `wenyan`, `wenyan-publish`, or a verified wenyan-based publisher. - - X Articles: use `x-articles` when installed/verified. Use Twitter/X post skills only for tweets/threads, not long articles. - - Substack: prefer draft/review flow (`substack-autopilot` or a verified generic Substack publisher) until account-specific publishing is confirmed. - - Video: consult `references/candidate-skills.md`; do not improvise video publishing. - -6. **Record results** - - Write `result.json` or append to `publish-log.md` in the run directory. - - Include target, status, mode, draft URL/public URL if available, timestamp, and error message. - - If a target fails, continue only when independent and safe; otherwise stop and report the blocker. - -## Safety and Approval Rules - -Follow `references/publishing-policy.md` strictly: - -- Never publish publicly without explicit confirmation. -- Never bypass account login, CAPTCHA, platform review, or anti-abuse safeguards. -- Prefer draft/save flows over direct publish. -- Treat cookies, API tokens, AppID/AppSecret, and session files as secrets; never print them. -- If browser automation reaches an ambiguous screen, stop and ask. - -## Common Commands / User Intents - -- “把这组图文同步发到小红书和微信图文” → `image-post`, targets `xiaohongshu`, `wechat-image`, default `draft`. -- “这篇长文同时发公众号、X 长文章、Substack” → `longform`, targets `wechat-article`, `x-article`, `substack`, default `draft`. -- “直接发布” → verify exact content and targets, then ask one final confirmation before public publish. -- “以后加视频号/抖音/B站” → update `platform-map.md` and add a `video-post` adapter; do not mix video assumptions into image/longform flows. - -## Bundled Resources - -- `references/manifest-schema.md` — canonical YAML fields and examples. -- `references/platform-map.md` — platform capability matrix and preferred candidate skills. -- `references/publishing-policy.md` — confirmation, privacy, and external-action rules. -- `references/candidate-skills.md` — researched ClawHub/local skills and integration notes. -- `references/workflows.md` — MVP implementation phases and operational checklists. -- `references/image-post-mvp.md` — Phase 2 Xiaohongshu + WeChat image-post adapter plan. -- `references/phase2-audit.md` — audit notes for installed `multi-post` and `social-media-publish`. -- `references/wechat-image-calibration.md` — WeChat browser fallback calibration status and unblock plan. -- `references/wechat-api-provider.md` — WeChat Official Account API draft provider design. -- `scripts/adapt_content.py` — scaffold generic platform-specific pack files from a manifest. -- `scripts/prepare_image_post.py` — validate image-post manifests and generate Xiaohongshu/WeChat image payloads + preview without publishing; also writes `packs/wechat-article-api-bridge/payload.json` for WeChat API dry-run compatibility. -- `scripts/execute_image_post.py` — draft-only executor for prepared image-post runs; currently creates Xiaohongshu local drafts and WeChat browser-flow guides. -- `scripts/wechat_api_draft.py` — draft-only WeChat Official Account API helper for access-token, cover upload, and draft creation. -- `scripts/prepare_longform.py` — validate longform manifests and generate `wechat-article`, `x-article`, and `substack` payloads + preview without publishing. -- `scripts/test_local.py` / `Makefile` — local smoke test for compile, image-post prepare/execute guide, Xiaohongshu local draft, WeChat API dry-run, and longform prepare. -- `scripts/publish_manifest.py` — validate manifest and create a run directory/log skeleton; dispatch remains manual/skill-driven until connectors are verified. -- `examples/image-post.yaml` and `examples/longform.yaml` — starter manifests. +Cross-platform content publishing orchestration. Routes one source content +package to multiple platform providers via a unified manifest, draft-first +safety policy, and per-platform rules. + +## When to use + +- User wants to publish/draft the same content across multiple platforms +- User wants to add a new platform/provider +- User needs to validate a manifest, set up credentials, or inspect runs + +## Entry point + +All operations go through `scripts/mmp.py`: + +```bash +python3 scripts/mmp.py [args] +``` + +Subcommands: + +- `validate ` — validate without executing +- `publish [--mode-override ...]` — prepare + execute +- `setup [--account NAME]` — configure credentials +- `list providers|accounts|runs` — inspect state +- `resume [--target NAME]` — recover failed run +- `doctor` — self-check +- `wizard [--type ... --targets ...]` — conversational manifest builder + +## Default mode = draft + +Every run defaults to `mode: draft`. Public publishing requires explicit +top-level `mode: publish` AND a second confirmation in conversation. + +## Wizard Mode + +When the user says "新发布", "帮我发一组到 X / Y", "publish to ...", "cross-post", +or pastes content with publishing intent, run the **3-stage wizard** instead of +asking them to write a manifest: + +1. **Stage 1 — Source Extraction**: read `core/wizard/source_extraction.md` and + follow the instructions there. Extract `type`, `title`, `body`, `cover`, + `images`, `tags`, `cta` into your conversation memory. Don't write files yet. + +2. **Stage 2 — Target Selection**: read `core/wizard/target_selection.md`. Run + `python3 scripts/mmp.py wizard --dump-context --type ` to fetch available + providers + credential status + accounts. Ask which targets, modes, accounts. + +3. **Stage 3 — Manifest Assembly**: read `core/wizard/manifest_assembly.md`. + Render YAML, write to a temp file, validate via `python3 scripts/mmp.py validate`, + show the user, get approval. On approve, run `python3 scripts/mmp.py wizard --commit `. + +For setup credentials flows ("配置凭证", "setup wechat-article account"), read +`core/wizard/credential_setup.md`. Direct the user to run `mmp setup ` +locally — never ask them to paste a secret into chat unless they insist. + +## Public-publish Gate + +If a wizard run would result in `mode: publish` for any target, ALWAYS: + +1. Show the rendered manifest first. +2. Ask "Confirm public publish? (yes/no)". +3. Proceed only on exact match `yes`. Anything else → downgrade to `draft`. + +This rule overrides any earlier user permission. Each public publish is a fresh +ask in the active conversation. + +## v0.2 supported providers + +| Provider | Media | Mode support | Notes | +|---|---|---|---| +| `wechat-article` | longform | dry-run, draft | Real WeChat OA API; needs AppID/AppSecret | +| `xiaohongshu` | image-post (video planned) | dry-run, draft (local) | Uses xiaohongshu skill's `draft.sh` | +| `wechat-image` | image-post | dry-run, draft (browser-flow guide) | UI calibration TODO; guide-only path | +| `x-article` | longform | dry-run, draft (payload + TODO) | No connector yet; manual paste step | +| `substack` | longform | dry-run, draft (payload + TODO) | No connector yet; manual paste step | + +## Safety rules + +1. Never publish publicly without explicit confirmation +2. Never bypass login, CAPTCHA, platform review, or anti-abuse safeguards +3. Treat all credentials as secrets; never print them +4. If browser automation reaches an ambiguous screen, stop and ask +5. Read `docs/safety-policy.md` (was `references/publishing-policy.md`) + +## Architecture + +See `docs/superpowers/specs/2026-05-05-multi-media-publisher-redesign-design.md` +for the full architecture spec. In short: + +- **Shell**: this SKILL.md + `.claude-plugin/plugin.json` +- **Core**: `core/` — host-agnostic Python (manifest, provider registry, vault, run lifecycle) +- **Providers**: `providers//` (bundled) + `~/.config/mmp/providers//` (user) + +## Quickstart + +```bash +# 1. Validate +python3 scripts/mmp.py validate examples/longform.yaml + +# 2. Configure WeChat credentials +python3 scripts/mmp.py setup wechat-article + +# 3. Dry-run +python3 scripts/mmp.py publish examples/longform.yaml --mode-override dry-run + +# 4. Inspect runs +python3 scripts/mmp.py list runs +``` + +## Bundled resources + +- `core/` — manifest, provider, credentials, run, rules, host, errors +- `providers/wechat_article/` — first-party WeChat OA article provider +- `examples/longform.yaml` — sample manifest +- `docs/HANDOFF.md` — historical state notes +- `docs/superpowers/specs/2026-05-05-multi-media-publisher-redesign-design.md` — v0.2 design spec +- `docs/superpowers/plans/` — v0.2 implementation plans (historical) diff --git a/core/__init__.py b/core/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/core/cli.py b/core/cli.py new file mode 100644 index 0000000..5f85e9a --- /dev/null +++ b/core/cli.py @@ -0,0 +1,33 @@ +"""Console entry point for the `mmp` command. + +Bridges from `pip install`'s `[project.scripts]` to the actual CLI logic +in `scripts/mmp.py`. Single-purpose: import + call. +""" + +from __future__ import annotations + +import sys +from pathlib import Path + + +def main() -> int: + """Entry point used by [project.scripts].""" + # Ensure repo root is on sys.path so `scripts/mmp.py` can be loaded. + repo_root = Path(__file__).resolve().parent.parent + sys.path.insert(0, str(repo_root)) + + # Import scripts/mmp.py via importlib (it's not a regular package member). + import importlib.util + + mmp_path = repo_root / "scripts" / "mmp.py" + spec = importlib.util.spec_from_file_location("_mmp_cli", mmp_path) + if not spec or not spec.loader: + print("ERROR: cannot load scripts/mmp.py", file=sys.stderr) + return 2 + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module.main() + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/core/credentials.py b/core/credentials.py new file mode 100644 index 0000000..bdf1024 --- /dev/null +++ b/core/credentials.py @@ -0,0 +1,161 @@ +"""Credential vault: encrypted file backend (age) + ENV backend. + +Vault layout (decrypted JSON): + { + "version": 1, + "accounts": { + ":": {"KEY": "value", ...}, + ... + } + } + +ENV always wins over the vault: any key already present in the vault is +overridden by an os.environ entry with the same name. To pull keys that +exist *only* in the environment (e.g. on CI with EnvBackend), callers must +pass ``required_keys=[...]`` explicitly. +""" + +from __future__ import annotations + +import json +import os +from abc import ABC, abstractmethod +from pathlib import Path + +import pyrage # type: ignore[import-untyped] + +from core import host +from core.errors import MissingCredentialError + +_VAULT_VERSION = 1 + + +def _ensure_dir(p: Path) -> None: + p.mkdir(parents=True, exist_ok=True) + + +def _read_or_create_key(key_path: Path) -> tuple[pyrage.x25519.Identity, pyrage.x25519.Recipient]: + """Return (identity, recipient). + + Priority: + 1. ENV MMP_VAULT_KEY (canonical for CI / one-shot use) + 2. existing key file at key_path + 3. generate a new key file (first use) + """ + env_key = os.environ.get("MMP_VAULT_KEY", "").strip() + if env_key: + identity = pyrage.x25519.Identity.from_str(env_key) + return identity, identity.to_public() + if not key_path.exists(): + identity = pyrage.x25519.Identity.generate() + key_path.parent.mkdir(parents=True, exist_ok=True) + key_path.write_text(str(identity), encoding="utf-8") + os.chmod(key_path, 0o600) + return identity, identity.to_public() + text = key_path.read_text(encoding="utf-8").strip() + identity = pyrage.x25519.Identity.from_str(text) + return identity, identity.to_public() + + +class Backend(ABC): + @abstractmethod + def read_all(self) -> dict[str, dict[str, str]]: ... + + @abstractmethod + def write_all(self, accounts: dict[str, dict[str, str]]) -> None: ... + + +class FileBackend(Backend): + """Age-encrypted JSON vault at host.vault_path().""" + + def __init__(self) -> None: + self._vault = host.vault_path() + self._key = host.vault_key_path() + + def read_all(self) -> dict[str, dict[str, str]]: + if not self._vault.exists(): + return {} + identity, _ = _read_or_create_key(self._key) + ciphertext = self._vault.read_bytes() + plaintext = pyrage.decrypt(ciphertext, [identity]) + data = json.loads(plaintext.decode("utf-8")) + if not isinstance(data, dict): + return {} + accounts = data.get("accounts", {}) + if not isinstance(accounts, dict): + return {} + return accounts + + def write_all(self, accounts: dict[str, dict[str, str]]) -> None: + _ensure_dir(self._vault.parent) + _, recipient = _read_or_create_key(self._key) + body = json.dumps( + {"version": _VAULT_VERSION, "accounts": accounts}, ensure_ascii=False + ).encode("utf-8") + ciphertext = pyrage.encrypt(body, [recipient]) + self._vault.write_bytes(ciphertext) + os.chmod(self._vault, 0o600) + + +class EnvBackend(Backend): + """Read-only backend that pulls from os.environ. Used in CI.""" + + def read_all(self) -> dict[str, dict[str, str]]: + return {} + + def write_all(self, accounts: dict[str, dict[str, str]]) -> None: + raise NotImplementedError("EnvBackend is read-only") + + +class CredentialStore: + """Vault facade. ENV always overrides vault.""" + + def __init__(self, backend: Backend | None = None) -> None: + self._backend: Backend = backend or FileBackend() + + def set(self, provider: str, account: str, values: dict[str, str]) -> None: + all_ = self._backend.read_all() + all_[f"{provider}:{account}"] = dict(values) + self._backend.write_all(all_) + + def get( + self, + provider: str, + account: str = "default", + required_keys: list[str] | None = None, + ) -> dict[str, str]: + key = f"{provider}:{account}" + all_ = self._backend.read_all() + vault_values = dict(all_.get(key, {})) + + # ENV override: any matching key in os.environ wins + merged = dict(vault_values) + for k in list(merged.keys()): + if k in os.environ: + merged[k] = os.environ[k] + + # Also pick up env-only keys when required_keys is given + if required_keys: + for k in required_keys: + if k not in merged and k in os.environ: + merged[k] = os.environ[k] + missing = [k for k in required_keys if k not in merged] + if missing: + raise MissingCredentialError(provider=provider, keys=missing) + return {k: merged[k] for k in required_keys} + + if not merged: + raise MissingCredentialError(provider=provider, keys=["*"]) + return merged + + def list_accounts(self, provider: str | None = None) -> list[str]: + all_ = self._backend.read_all() + if provider is None: + return sorted(all_.keys()) + prefix = f"{provider}:" + return [k.removeprefix(prefix) for k in sorted(all_.keys()) if k.startswith(prefix)] + + def delete(self, provider: str, account: str) -> None: + all_ = self._backend.read_all() + all_.pop(f"{provider}:{account}", None) + self._backend.write_all(all_) diff --git a/core/errors.py b/core/errors.py new file mode 100644 index 0000000..b84b89c --- /dev/null +++ b/core/errors.py @@ -0,0 +1,46 @@ +"""Exception hierarchy for multi-media-publisher core.""" + +from __future__ import annotations + + +class MMPError(Exception): + """Base class for all multi-media-publisher errors.""" + + +class ManifestError(MMPError): + """Manifest schema or validation failure.""" + + +class ProviderNotFoundError(MMPError): + """Requested provider not registered.""" + + +class MissingCredentialError(MMPError): + """Required credentials not available in vault or ENV.""" + + def __init__(self, provider: str, keys: list[str]) -> None: + self.provider = provider + self.keys = keys + super().__init__(f"missing credentials for {provider}: {keys}") + + +class PlatformRuleViolation(MMPError): + """Manifest violates a provider's platform rules at error severity.""" + + +class ProviderExecutionError(MMPError): + """Provider.execute raised; carries enough metadata for resume.""" + + def __init__( + self, + target: str, + step: str, + upstream: Exception | None = None, + retryable: bool = False, + ) -> None: + self.target = target + self.step = step + self.upstream = upstream + self.retryable = retryable + msg = f"{target} failed at {step}: {upstream}" + super().__init__(msg) diff --git a/core/host.py b/core/host.py new file mode 100644 index 0000000..0f9188b --- /dev/null +++ b/core/host.py @@ -0,0 +1,53 @@ +"""Host environment detection and XDG-compliant path resolution. + +Single source of truth for any filesystem path that depends on user environment. +Pure functions: no mutation, no I/O beyond os.environ reads. +""" + +from __future__ import annotations + +import os +from pathlib import Path + +_APP = "mmp" + + +def user_data_dir() -> Path: + """Resolve user config root: $XDG_CONFIG_HOME/mmp or ~/.config/mmp.""" + xdg = os.environ.get("XDG_CONFIG_HOME") + base = Path(xdg) if xdg else Path.home() / ".config" + return base / _APP + + +def vault_path() -> Path: + return user_data_dir() / "credentials.json.age" + + +def vault_key_path() -> Path: + return user_data_dir() / "age-key.txt" + + +def user_providers_dir() -> Path: + return user_data_dir() / "providers" + + +def settings_path() -> Path: + return user_data_dir() / "settings.toml" + + +def runs_dir() -> Path: + """Where run dirs are written. ENV override > skill-relative default.""" + env = os.environ.get("MMP_RUNS_DIR") + if env: + return Path(env) + # default: /runs + return Path(__file__).resolve().parent.parent / "runs" + + +def detect_host() -> str: + """Best-effort host detection. Used only for telemetry in result.json.""" + if os.environ.get("CLAUDE_CODE_VERSION") or os.environ.get("CLAUDECODE"): + return "claude-code" + if os.environ.get("OPENCLAW_VERSION") or Path.home().joinpath(".openclaw").exists(): + return "openclaw" + return "unknown" diff --git a/core/manifest.py b/core/manifest.py new file mode 100644 index 0000000..6cc235f --- /dev/null +++ b/core/manifest.py @@ -0,0 +1,219 @@ +"""Manifest schema, loading, normalization, and validation. + +A manifest is the user-facing YAML; once loaded it becomes a Manifest dataclass. +The lock-form (manifest.lock.json) is the normalized version emitted by +to_lock_dict for downstream tools. +""" + +from __future__ import annotations + +import json +from dataclasses import dataclass, field +from pathlib import Path +from typing import Any + +import yaml + +from core.errors import ManifestError + +VALID_TYPES = {"image-post", "longform", "video-post"} +VALID_MODES = {"dry-run", "draft", "publish"} +SCHEMA_VERSION = "0.2" + + +@dataclass +class Target: + name: str + mode: str = "dry-run" + account: str = "default" + options: dict[str, Any] = field(default_factory=dict) + + def to_dict(self) -> dict[str, Any]: + return { + "name": self.name, + "mode": self.mode, + "account": self.account, + "options": dict(self.options), + } + + +@dataclass +class Manifest: + schema_version: str + type: str + title: str + body: str + mode: str + targets: list[Target] + summary: str | None = None + language: str = "zh-CN" + cover: str | None = None + images: list[str] = field(default_factory=list) + video: str | None = None + tags: list[str] = field(default_factory=list) + cta: str | None = None + metadata: dict[str, Any] = field(default_factory=dict) + source_path: Path | None = None # not serialized; for relative-path resolution + + def to_lock_dict(self) -> dict[str, Any]: + return { + "schema_version": self.schema_version, + "type": self.type, + "title": self.title, + "body": self.body, + "summary": self.summary, + "mode": self.mode, + "language": self.language, + "cover": self.cover, + "images": list(self.images), + "video": self.video, + "tags": list(self.tags), + "cta": self.cta, + "metadata": dict(self.metadata), + "targets": [t.to_dict() for t in self.targets], + } + + +def load_manifest(path: str | Path) -> Manifest: + p = Path(path).resolve() + if not p.exists(): + raise ManifestError(f"manifest not found: {p}") + try: + raw = yaml.safe_load(p.read_text(encoding="utf-8")) + except yaml.YAMLError as e: + raise ManifestError(f"invalid YAML in manifest {p}: {e}") from e + if not isinstance(raw, dict): + raise ManifestError(f"manifest must be a YAML mapping at top level: {p}") + return _from_dict(raw, base_dir=p.parent, source=p) + + +def _from_dict(raw: dict[str, Any], base_dir: Path, source: Path) -> Manifest: + for required in ("schema_version", "type", "title", "body", "mode", "targets"): + if required not in raw: + raise ManifestError(f"manifest missing required field: {required}") + + sv = str(raw["schema_version"]) + if sv != SCHEMA_VERSION: + raise ManifestError(f"unsupported schema_version {sv}; expected {SCHEMA_VERSION}") + + type_ = raw["type"] + if type_ not in VALID_TYPES: + raise ManifestError(f"invalid type {type_!r}; must be one of {sorted(VALID_TYPES)}") + + mode = raw["mode"] + if mode not in VALID_MODES: + raise ManifestError(f"invalid mode {mode!r}; must be one of {sorted(VALID_MODES)}") + + body_field = raw["body"] + body = _resolve_inline_or_path(body_field, base_dir) + + defaults = raw.get("defaults") or {} + if not isinstance(defaults, dict): + raise ManifestError("defaults must be a mapping") + default_account = defaults.get("account", "default") + default_options = defaults.get("options", {}) or {} + + raw_targets = raw["targets"] + if not isinstance(raw_targets, list) or not raw_targets: + raise ManifestError("targets must be a non-empty list") + targets = [_parse_target(t, mode, default_account, default_options) for t in raw_targets] + + assets = raw.get("assets") or {} + cover = _resolve_asset_path(assets.get("cover"), base_dir) + images = [ + resolved + for p in (assets.get("images") or []) + if (resolved := _resolve_asset_path(p, base_dir)) is not None + ] + video = _resolve_asset_path(assets.get("video"), base_dir) + + return Manifest( + schema_version=sv, + type=type_, + title=str(raw["title"]), + body=body, + mode=mode, + targets=targets, + summary=raw.get("summary"), + language=raw.get("language", "zh-CN"), + cover=cover, + images=images, + video=video, + tags=list(raw.get("tags") or []), + cta=raw.get("cta"), + metadata=dict(raw.get("metadata") or {}), + source_path=source, + ) + + +def _resolve_asset_path(value: Any, base_dir: Path) -> str | None: + """Resolve a relative asset path (cover/image/video) to absolute. + + Strings starting with ``./`` or ``../`` are joined with base_dir and + resolved. Absolute paths and ``None`` pass through. Other strings (e.g. + ``http://...`` URLs) also pass through unchanged. + """ + if value is None: + return None + if not isinstance(value, str): + raise ManifestError(f"asset path must be string, got {type(value).__name__}") + if value.startswith("./") or value.startswith("../"): + return str((base_dir / value).resolve()) + return value + + +def _resolve_inline_or_path(value: Any, base_dir: Path) -> str: + if not isinstance(value, str): + raise ManifestError("body must be a string (inline) or path string") + s = value.strip() + # Explicit path: ./ or ../ prefix => MUST resolve, error if missing. + if s.startswith("./") or s.startswith("../"): + candidate = (base_dir / s).resolve() + if not candidate.exists(): + raise ManifestError(f"body path not found: {candidate} (from {value!r})") + return candidate.read_text(encoding="utf-8") + # Heuristic: .md suffix without explicit prefix => path if exists, else inline. + if s.endswith(".md"): + candidate = (base_dir / s).resolve() + if candidate.exists(): + return candidate.read_text(encoding="utf-8") + return value + + +def _parse_target( + raw: Any, + top_mode: str, + default_account: str, + default_options: dict[str, Any], +) -> Target: + if isinstance(raw, str): + return Target( + name=raw, + mode=top_mode, + account=default_account, + options=dict(default_options), + ) + if isinstance(raw, dict): + if "target" not in raw: + raise ManifestError(f"target object missing 'target' key: {raw}") + merged_options = dict(default_options) + merged_options.update(raw.get("options") or {}) + m = raw.get("mode", top_mode) + if m not in VALID_MODES: + raise ManifestError(f"invalid target mode {m!r}") + return Target( + name=raw["target"], + mode=m, + account=raw.get("account", default_account), + options=merged_options, + ) + raise ManifestError(f"target must be string or mapping, got {type(raw).__name__}") + + +def write_lock(manifest: Manifest, run_dir: Path) -> Path: + lock_path = run_dir / "manifest.lock.json" + lock_path.write_text( + json.dumps(manifest.to_lock_dict(), indent=2, ensure_ascii=False), + encoding="utf-8", + ) + return lock_path diff --git a/core/provider.py b/core/provider.py new file mode 100644 index 0000000..6cc150b --- /dev/null +++ b/core/provider.py @@ -0,0 +1,177 @@ +"""Provider abstract base class + registry. + +Discovery rules: + - bundled_dir: scan /*/provider.yaml + - user_dir: scan /*/provider.yaml + - User provider with same `name` overrides bundled, but only when + discover(trust_user=True) — otherwise warn and skip. + +Two names per provider: + - directory name: snake_case (Python module path) + - provider.yaml `name`: kebab-case (manifest target name) +""" + +from __future__ import annotations + +import importlib.util +import sys +from abc import ABC, abstractmethod +from dataclasses import dataclass, field +from enum import Enum +from pathlib import Path +from typing import Any + +import yaml + +from core.errors import ProviderNotFoundError +from core.rules import PlatformRules, Violation + + +class HealthStatus(str, Enum): + ok = "ok" + failed = "failed" + unknown = "unknown" + + +@dataclass +class CredentialSpec: + key: str + description: str = "" + secret: bool = True + setup_hint: str = "" + + +@dataclass +class ValidationResult: + violations: list[Violation] = field(default_factory=list) + + +@dataclass +class PreparedPayload: + pack_dir: Path + payload_path: Path + extras: dict[str, Path] = field(default_factory=dict) + + +@dataclass +class ExecutionResult: + status: str # "ok" | "failed" | "skipped" | "partial" + mode_actual: str # "dry-run" | "stub" | "draft-local" | "draft-platform" | "published" + external_id: str | None = None + draft_url: str | None = None + extras: dict[str, Any] = field(default_factory=dict) + + +@dataclass +class ProviderInfo: + name: str + display_name: str + media_types: list[str] + capabilities: dict[str, bool] + required_credentials: list[CredentialSpec] + source: str # "bundled" | "user" + + +class Provider(ABC): + name: str + display_name: str + media_types: list[str] + capabilities: dict[str, bool] + required_credentials: list[CredentialSpec] + platform_rules: PlatformRules + + @abstractmethod + def validate(self, manifest: Any, target: Any) -> ValidationResult | None: ... + + @abstractmethod + def prepare(self, manifest: Any, target: Any, run_dir: Path) -> PreparedPayload | None: ... + + @abstractmethod + def execute( + self, + run_dir: Path, + target: Any, + mode: str, + credentials: dict[str, str], + ) -> ExecutionResult | None: ... + + def health_check(self, credentials: dict[str, str]) -> HealthStatus: + return HealthStatus.unknown + + +class ProviderRegistry: + def __init__(self, bundled_dir: Path | None = None, user_dir: Path | None = None) -> None: + self._bundled_dir = bundled_dir or _default_bundled_dir() + self._user_dir = user_dir + self._providers: dict[str, Provider] = {} + self._info: dict[str, ProviderInfo] = {} + + def discover(self, trust_user: bool = False) -> None: + """Scan bundled and (optionally) user provider directories. + + ``trust_user`` is the POST-confirmation gate, not a bypass. + Pass True only after the user has explicitly confirmed loading + each user-installed provider (typically via SKILL.md prompt + and a write to ``settings.toml.providers.trusted_user_providers``). + Tests pass True directly to exercise the override path. + """ + self._providers.clear() + self._info.clear() + # bundled first + if self._bundled_dir and self._bundled_dir.exists(): + for d in sorted(self._bundled_dir.iterdir()): + if d.is_dir() and (d / "provider.yaml").exists(): + self._load_provider(d, source="bundled") + # then user (overrides if trusted) + if trust_user and self._user_dir and self._user_dir.exists(): + for d in sorted(self._user_dir.iterdir()): + if d.is_dir() and (d / "provider.yaml").exists(): + self._load_provider(d, source="user") + + def resolve(self, name: str) -> Provider: + if name not in self._providers: + raise ProviderNotFoundError(f"provider not registered: {name}") + return self._providers[name] + + def list(self, media_type: str | None = None) -> list[ProviderInfo]: + infos = list(self._info.values()) + if media_type: + infos = [i for i in infos if media_type in i.media_types] + return infos + + def _load_provider(self, pdir: Path, source: str) -> None: + meta = yaml.safe_load((pdir / "provider.yaml").read_text(encoding="utf-8")) + name = meta["name"] + entry = meta["entry"] # e.g. "provider:FakeProvider" + module_file, class_name = entry.split(":", 1) + module_path = pdir / f"{module_file}.py" + if not module_path.exists(): + raise FileNotFoundError(f"provider entry module not found: {module_path}") + + mod_name = f"_mmp_provider_{pdir.name}" + spec = importlib.util.spec_from_file_location( + mod_name, module_path, submodule_search_locations=[str(pdir)] + ) + if not spec or not spec.loader: + raise FileNotFoundError(f"cannot create import spec for provider at {module_path}") + module = importlib.util.module_from_spec(spec) + sys.modules[mod_name] = module + spec.loader.exec_module(module) + cls = getattr(module, class_name) + instance = cls() + + creds = [CredentialSpec(**c) for c in (meta.get("required_credentials") or [])] + info = ProviderInfo( + name=name, + display_name=meta.get("display_name", name), + media_types=list(meta.get("media_types") or []), + capabilities=dict(meta.get("capabilities") or {}), + required_credentials=creds, + source=source, + ) + self._providers[name] = instance + self._info[name] = info + + +def _default_bundled_dir() -> Path: + return Path(__file__).resolve().parent.parent / "providers" diff --git a/core/rules.py b/core/rules.py new file mode 100644 index 0000000..327893e --- /dev/null +++ b/core/rules.py @@ -0,0 +1,105 @@ +"""Platform rules-as-code: declarative per-platform constraints + lint.""" + +from __future__ import annotations + +from collections.abc import Callable +from dataclasses import dataclass, field +from enum import Enum +from typing import Any, Protocol + + +class Severity(str, Enum): + error = "error" + warning = "warning" + info = "info" + + +@dataclass +class Violation: + code: str + message: str + severity: Severity = Severity.error + target: str | None = None + field_path: str | None = None + + +class _ManifestLike(Protocol): + title: str + body: str + images: list[str] + tags: list[str] + + +LintFn = Callable[[Any, str], list[Violation]] + + +@dataclass +class PlatformRules: + title_max: int | None = None + body_max: int | None = None + image_count_min: int | None = None + image_count_max: int | None = None + image_aspect_ratios: list[str] | None = None + tag_max: int | None = None + cover_required: bool = False + cover_aspect_ratios: list[str] | None = None + extra_lints: list[LintFn] = field(default_factory=list) + + def lint(self, manifest: _ManifestLike, target_name: str) -> list[Violation]: + violations: list[Violation] = [] + + title = getattr(manifest, "title", "") or "" + body = getattr(manifest, "body", "") or "" + images = getattr(manifest, "images", []) or [] + tags = getattr(manifest, "tags", []) or [] + + if self.title_max is not None and len(title) > self.title_max: + violations.append( + Violation( + code="TITLE_TOO_LONG", + message=f"title length {len(title)} exceeds {self.title_max}", + target=target_name, + field_path="title", + ) + ) + if self.body_max is not None and len(body) > self.body_max: + violations.append( + Violation( + code="BODY_TOO_LONG", + message=f"body length {len(body)} exceeds {self.body_max}", + target=target_name, + field_path="body", + ) + ) + if self.image_count_min is not None and len(images) < self.image_count_min: + violations.append( + Violation( + code="IMAGE_COUNT_BELOW_MIN", + message=f"image count {len(images)} below min {self.image_count_min}", + target=target_name, + field_path="assets.images", + ) + ) + if self.image_count_max is not None and len(images) > self.image_count_max: + violations.append( + Violation( + code="IMAGE_COUNT_ABOVE_MAX", + message=f"image count {len(images)} above max {self.image_count_max}", + target=target_name, + field_path="assets.images", + ) + ) + if self.tag_max is not None and len(tags) > self.tag_max: + violations.append( + Violation( + code="TAG_COUNT_ABOVE_MAX", + message=f"tag count {len(tags)} above max {self.tag_max}", + target=target_name, + field_path="tags", + ) + ) + + for fn in self.extra_lints: + violations.extend(fn(manifest, target_name)) + + return violations diff --git a/core/run.py b/core/run.py new file mode 100644 index 0000000..e3948d7 --- /dev/null +++ b/core/run.py @@ -0,0 +1,181 @@ +"""Run lifecycle: directory creation, checkpoints, result.json, publish-log.md.""" + +from __future__ import annotations + +import json +import re +from dataclasses import asdict, dataclass, field +from datetime import datetime, timezone +from pathlib import Path +from typing import Any + +from core import host + +_RESULT_SCHEMA_VERSION = 1 + + +def _now_iso() -> str: + return datetime.now(timezone.utc).isoformat(timespec="seconds").replace("+00:00", "Z") + + +def _now_id() -> str: + return datetime.now().strftime("%Y%m%d-%H%M%S") + + +def slugify(text: str, max_len: int = 40) -> str: + """ASCII-friendly lowercase slug. + + Non-ASCII characters are dropped (NOT transliterated). Whitespace and + underscores collapse to hyphens. Empty / non-word input becomes + 'untitled'. Max length truncates trailing hyphens. + """ + s = (text or "").lower().strip() + # Drop non-ASCII + s = s.encode("ascii", "ignore").decode("ascii") + # Strip non-(word/whitespace/hyphen) + s = re.sub(r"[^\w\s-]+", "", s) + s = re.sub(r"[\s_]+", "-", s) + s = re.sub(r"-+", "-", s).strip("-") + if not s: + s = "untitled" + return s[:max_len].rstrip("-") + + +@dataclass +class _TargetResult: + name: str + account: str + status: str + mode_actual: str + external_id: str | None = None + draft_url: str | None = None + started_at: str = field(default_factory=_now_iso) + completed_at: str | None = None + error: str | None = None + violations: list[dict[str, Any]] = field(default_factory=list) + + +@dataclass +class Run: + run_id: str + dir: Path + mode: str + mmp_version: str + host: str + started_at: str = field(default_factory=_now_iso) + completed_at: str | None = None + targets: list[_TargetResult] = field(default_factory=list) + + @classmethod + def create(cls, title: str, mmp_version: str, host: str, mode: str) -> Run: + base_rid = f"{_now_id()}-{slugify(title)}" + runs_root = host_runs_dir() + # Resolve collision by appending -2, -3, ... if the base_rid dir already exists. + rid = base_rid + n = 1 + while (runs_root / rid).exists(): + n += 1 + rid = f"{base_rid}-{n}" + d = runs_root / rid + d.mkdir(parents=True) + for sub in ("packs", "checkpoints", "artifacts"): + (d / sub).mkdir() + run = cls(run_id=rid, dir=d, mode=mode, mmp_version=mmp_version, host=host) + run.log("RUN_START", run_id=rid, mode=mode) + return run + + @classmethod + def from_dir(cls, run_dir: Path) -> Run: + # Reconstruct minimal state from disk (used for resume). + rid = run_dir.name + mode = "draft" + # Try result.json if present + rp = run_dir / "result.json" + if rp.exists(): + data = json.loads(rp.read_text(encoding="utf-8")) + mode = data.get("mode", mode) + return cls(run_id=rid, dir=run_dir, mode=mode, mmp_version="?", host="?") + + def add_target_result( + self, + name: str, + account: str, + status: str, + mode_actual: str, + external_id: str | None = None, + draft_url: str | None = None, + error: str | None = None, + violations: list[dict[str, Any]] | None = None, + ) -> None: + self.targets.append( + _TargetResult( + name=name, + account=account, + status=status, + mode_actual=mode_actual, + external_id=external_id, + draft_url=draft_url, + completed_at=_now_iso(), + error=error, + violations=violations or [], + ) + ) + + def log(self, event: str, **kwargs: Any) -> None: + line_parts = [_now_iso(), event] + for k, v in kwargs.items(): + line_parts.append(f"{k}={v}") + line = " ".join(line_parts) + log_path = self.dir / "publish-log.md" + with log_path.open("a", encoding="utf-8") as f: + f.write(line + "\n") + + def checkpoint(self, target: str, step: str, **extras: Any) -> None: + cp_dir = self.dir / "checkpoints" + cp_dir.mkdir(exist_ok=True) + path = cp_dir / f"{target}.checkpoint.json" + payload = { + "target": target, + "step": step, + "started_at": _now_iso(), + "external_ids": extras.pop("external_ids", {}), + "next_step": extras.pop("next_step", None), + **extras, + } + path.write_text(json.dumps(payload, indent=2, ensure_ascii=False), encoding="utf-8") + + def read_checkpoint(self, target: str) -> dict[str, Any] | None: + path = self.dir / "checkpoints" / f"{target}.checkpoint.json" + if not path.exists(): + return None + return json.loads(path.read_text(encoding="utf-8")) + + def finalize(self) -> None: + self.completed_at = _now_iso() + result = { + "run_id": self.run_id, + "schema_version": _RESULT_SCHEMA_VERSION, + "manifest_path": "manifest.yaml", + "started_at": self.started_at, + "completed_at": self.completed_at, + "mode": self.mode, + "host": self.host, + "mmp_version": self.mmp_version, + "targets": [asdict(t) for t in self.targets], + } + (self.dir / "result.json").write_text( + json.dumps(result, indent=2, ensure_ascii=False), encoding="utf-8" + ) + if not self.targets: + overall = "empty" + elif all(t.status == "ok" for t in self.targets): + overall = "ok" + else: + overall = "partial" + self.log("RUN_DONE", overall=overall) + + +def host_runs_dir() -> Path: + d = host.runs_dir() + d.mkdir(parents=True, exist_ok=True) + return d diff --git a/core/settings.py b/core/settings.py new file mode 100644 index 0000000..dfe8612 --- /dev/null +++ b/core/settings.py @@ -0,0 +1,64 @@ +"""Settings persistence at ~/.config/mmp/settings.toml. + +Defaults are returned when the file is missing; save() writes back the full +settings object. +""" + +from __future__ import annotations + +import sys +from dataclasses import dataclass, field +from pathlib import Path + +if sys.version_info >= (3, 11): + import tomllib +else: # pragma: no cover + # tomli is only installed on py3.10 (see pyproject.toml conditional dep); + # mypy on py3.11+ can't resolve the import and we don't want it to. + import tomli as tomllib # type: ignore[import-not-found,unused-ignore] + +import tomli_w + +from core import host + + +@dataclass +class Settings: + default_mode: str = "draft" + wizard_enabled: bool = True + auto_save_manifest: bool = True + trusted_user_providers: list[str] = field(default_factory=list) + + +def load() -> Settings: + p = host.settings_path() + if not p.exists(): + return Settings() + with p.open("rb") as f: + data = tomllib.load(f) + wiz = data.get("wizard", {}) + providers = data.get("providers", {}) + return Settings( + default_mode=data.get("default_mode", "draft"), + wizard_enabled=wiz.get("enabled", True), + auto_save_manifest=wiz.get("auto_save_manifest", True), + trusted_user_providers=list(providers.get("trusted_user_providers", [])), + ) + + +def save(s: Settings) -> Path: + p = host.settings_path() + p.parent.mkdir(parents=True, exist_ok=True) + payload = { + "default_mode": s.default_mode, + "wizard": { + "enabled": s.wizard_enabled, + "auto_save_manifest": s.auto_save_manifest, + }, + "providers": { + "trusted_user_providers": list(s.trusted_user_providers), + }, + } + with p.open("wb") as f: + tomli_w.dump(payload, f) + return p diff --git a/core/wizard/__init__.py b/core/wizard/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/core/wizard/commit.py b/core/wizard/commit.py new file mode 100644 index 0000000..0f93831 --- /dev/null +++ b/core/wizard/commit.py @@ -0,0 +1,25 @@ +"""Commit a validated manifest from the wizard into a fresh run dir.""" + +from __future__ import annotations + +from pathlib import Path + +from core.manifest import load_manifest, write_lock +from core.run import Run + + +def commit_manifest(src_path: str | Path) -> Path: + src = Path(src_path).resolve() + manifest = load_manifest(src) + + run = Run.create( + title=manifest.title, + mmp_version="0.2.0", + host="wizard", + mode=manifest.mode, + ) + target_path = run.dir / "manifest.yaml" + target_path.write_text(src.read_text(encoding="utf-8"), encoding="utf-8") + write_lock(manifest, run.dir) + run.log("WIZARD_COMMIT", source=str(src)) + return run.dir diff --git a/core/wizard/context.py b/core/wizard/context.py new file mode 100644 index 0000000..73d0960 --- /dev/null +++ b/core/wizard/context.py @@ -0,0 +1,102 @@ +"""Build a JSON context object describing current wizard state. + +Claude reads this (via `mmp wizard --dump-context`) to know which providers +are available, which accounts have credentials, and what the user's settings +say. The context is the bridge between Python state and Claude conversation. +""" + +from __future__ import annotations + +from pathlib import Path +from typing import Any + +from core import settings as settings_mod +from core.credentials import CredentialStore +from core.errors import MissingCredentialError +from core.provider import ProviderRegistry + + +def _account_status( + store: CredentialStore, + provider_name: str, + account: str, + required_keys: list[str], +) -> str: + if not required_keys: + return "n/a" + try: + store.get(provider_name, account, required_keys=required_keys) + return "ok" + except MissingCredentialError: + return "missing" + + +def build_context( + bundled_dir: Path | None = None, + user_dir: Path | None = None, + media_type: str | None = None, +) -> dict[str, Any]: + reg = ProviderRegistry(bundled_dir=bundled_dir, user_dir=user_dir) + s = settings_mod.load() + # TODO(plan-4): pass trust_user from settings.trusted_user_providers + # to enable third-party providers from ~/.config/mmp/providers/. + reg.discover(trust_user=False) + + store = CredentialStore() + flat_accounts = store.list_accounts() # ["provider:account", ...] + + # Group accounts by provider name for the JSON output + accounts_by_provider: dict[str, list[str]] = {} + for entry in flat_accounts: + if ":" not in entry: + continue + prov, acct = entry.split(":", 1) + accounts_by_provider.setdefault(prov, []).append(acct) + + providers_out: list[dict[str, Any]] = [] + for info in reg.list(media_type=media_type): + keys = [c.key for c in info.required_credentials] + + # Determine accounts to inspect for this provider + provider_accounts = accounts_by_provider.get(info.name, []) + + if not keys: + # Provider needs no credentials — always ok + cred_status = "n/a" + accounts_detail: list[dict[str, str]] = [] + elif not provider_accounts: + # Needs credentials but vault has no accounts for this provider + cred_status = "missing" + accounts_detail = [] + else: + accounts_detail = [ + {"name": acct, "status": _account_status(store, info.name, acct, keys)} + for acct in sorted(provider_accounts) + ] + cred_status = "ok" if any(a["status"] == "ok" for a in accounts_detail) else "missing" + + providers_out.append( + { + "name": info.name, + "display_name": info.display_name, + "media_types": info.media_types, + "capabilities": info.capabilities, + "required_credentials": [ + {"key": c.key, "description": c.description, "secret": c.secret} + for c in info.required_credentials + ], + "credential_status": cred_status, + "accounts": accounts_detail, + "source": info.source, + } + ) + + return { + "providers": providers_out, + "accounts": accounts_by_provider, + "settings": { + "default_mode": s.default_mode, + "wizard_enabled": s.wizard_enabled, + "auto_save_manifest": s.auto_save_manifest, + }, + } diff --git a/core/wizard/credential_setup.md b/core/wizard/credential_setup.md new file mode 100644 index 0000000..55f6bd5 --- /dev/null +++ b/core/wizard/credential_setup.md @@ -0,0 +1,67 @@ +# Wizard — Credential Setup + +Triggered when: +- User says "setup credentials" / "添加账号" / "configure " +- A different stage discovers `credential_status: missing` for a target + +## Goal + +Populate `~/.config/mmp/credentials.json.age` with the keys the chosen +provider's `required_credentials` list. ENV variables override vault for +the current session. + +## How to behave + +1. **Identify provider + account**. + - Provider: ask if not given (e.g. "Which provider? wechat-article / xiaohongshu / x-article / substack") + - Account: default is `default`. Multi-account users may want `lewis`, `work`, etc. + +2. **Show what's needed**: run + + ```bash + python3 scripts/mmp.py wizard --dump-context | jq '.providers[] | select(.name=="")' + ``` + + List each `required_credentials` entry with its `description` and `setup_hint`. + +3. **For each key**, prompt the user: + - If `secret: false`: ask normally; the value is shown in the conversation (e.g. AppID). + - If `secret: true`: instruct user to paste in chat; warn that this conversation may be logged on their side. **Never echo the secret back in your reply.** Confirm receipt with "(received)". + +4. **Persist** by running: + + ```bash + python3 scripts/mmp.py setup --account + ``` + + This subcommand prompts via stdin/getpass for each key. **Tell the user this + is the safer path** — they enter the secret directly into the CLI, never + into chat. + + Alternative (one-shot, less secure): pass through ENV-prefixed values: + + ```bash + WECHAT_APP_ID=wx... WECHAT_APP_SECRET=... \ + python3 scripts/mmp.py publish manifest.yaml + ``` + +5. **Verify**: run + + ```bash + python3 scripts/mmp.py doctor + ``` + + Confirm the account appears in `accounts:` count. + +6. **Optional: health check** (only if user wants the network round-trip): + + For wechat-article: verify by calling `get_access_token`. The provider's + `health_check` returns `ok` / `failed`. We do not expose this in the CLI in + v0.2; tell the user it'll come in v0.3. + +## Security reminders to surface + +- Tell the user: "I will not store, re-display, or log this secret." +- If the user pastes a secret in chat, advise them: "Consider rotating this key + after we're done — chat history is on your side." +- Never pass secrets as command-line arguments (visible in `ps`). diff --git a/core/wizard/loader.py b/core/wizard/loader.py new file mode 100644 index 0000000..cd5ba6e --- /dev/null +++ b/core/wizard/loader.py @@ -0,0 +1,34 @@ +"""Load and render markdown prompt fragments for the wizard. + +Templating: tiny {{var}} substitution, KeyError on missing var. Not Jinja — +prompts should stay simple and human-editable. +""" + +from __future__ import annotations + +import re +from pathlib import Path + +_STAGES = ["source_extraction", "target_selection", "manifest_assembly", "credential_setup"] +_PLACEHOLDER = re.compile(r"\{\{\s*(\w+)\s*\}\}") +_FRAGMENT_DIR = Path(__file__).resolve().parent + + +def list_stages() -> list[str]: + return list(_STAGES) + + +def render(stage_or_path: str | Path, **vars: object) -> str: + if isinstance(stage_or_path, Path): + path = stage_or_path + else: + path = _FRAGMENT_DIR / f"{stage_or_path}.md" + text = path.read_text(encoding="utf-8") + + def _sub(match: re.Match[str]) -> str: + key = match.group(1) + if key not in vars: + raise KeyError(f"missing wizard variable: {key}") + return str(vars[key]) + + return _PLACEHOLDER.sub(_sub, text) diff --git a/core/wizard/manifest_assembly.md b/core/wizard/manifest_assembly.md new file mode 100644 index 0000000..a3614d5 --- /dev/null +++ b/core/wizard/manifest_assembly.md @@ -0,0 +1,90 @@ +# Wizard Stage 3 — Manifest Assembly + +You now have a Stage 1 draft and a Stage 2 target list. Time to render the +manifest, validate it, get user approval, and persist. + +## Goal of this stage + +1. Render `manifest.yaml` from collected info. +2. Validate via `python3 scripts/mmp.py validate `. +3. Show the user the rendered YAML AND the violation report. +4. On approval, commit via `python3 scripts/mmp.py wizard --commit `. + +## Render rules + +- Always include `schema_version: "0.2"` at the top. +- Use full-form targets when modes/accounts/options differ; short-form (string) + when all defaults apply. +- For body: if the user pasted file content, write the file out to a sibling + path and reference it with `./.md`. If body is short and inline, embed. +- Cover and images: keep absolute paths if user gave absolute; otherwise + relative to the manifest file. + +## Validation flow + +1. Write the rendered YAML to a temp path: + + ```bash + tmp=$(mktemp -d)/manifest.yaml + # write yaml content to $tmp + ``` + +2. Run validate: + + ```bash + python3 scripts/mmp.py validate "$tmp" + ``` + +3. Parse the output: + - Exit 0 + "OK" → green light. + - Exit 2 with "ERROR" lines → blocking violations; surface each as + "violation: " and tell the user how to fix. + - Lines starting with "WARN" → soft warnings; show them but allow continue. + +## Approval gate + +Show the user: + +``` +Manifest: + + +Validation: +[errors and warnings listed] + +Confirm? (y/n/edit) +``` + +- `y` → commit. +- `n` → return to Stage 2 to revise targets, or Stage 1 to revise content. +- `edit` → ask which field to change, modify, re-render, re-validate. + +## Publish-mode safety gate + +If ANY target has `mode: publish`, after the user says `y`, ask one more time: + +> The following targets will publish PUBLICLY (not just draft): +> - wechat-article (account: default) +> - x-article (account: lewis) +> +> Confirm public publish? (yes/no) + +Only proceed on exact match `yes`. Anything else → downgrade to `draft` for +safety and inform the user. + +## Commit + +```bash +python3 scripts/mmp.py wizard --commit /tmp/manifest.yaml +``` + +The CLI prints `RUN_DIR `. The manifest is now at `/manifest.yaml` +and a `manifest.lock.json` is generated. + +## Hand-off + +Tell the user the run dir and ask whether to also execute now: + +> Manifest ready at `/manifest.yaml`. Execute now? +> yes → run `mmp publish /manifest.yaml` +> later → I'll stop here; run `mmp publish` when you're ready. diff --git a/core/wizard/source_extraction.md b/core/wizard/source_extraction.md new file mode 100644 index 0000000..58c98c5 --- /dev/null +++ b/core/wizard/source_extraction.md @@ -0,0 +1,63 @@ +# Wizard Stage 1 — Source Extraction + +You are guiding the user to convert source content into a structured draft. +**Do not write the manifest yet** — that happens in Stage 3 (`manifest_assembly.md`). + +## Goal of this stage + +Produce an internal draft holding these fields (in your conversation memory, not on disk yet): + +- `type`: one of `image-post` / `longform` / `video-post` +- `title`: short title +- `body`: full content (Markdown for longform; caption text for image-post) +- `summary`: optional one-line synopsis +- `cover`: optional path to a cover image +- `images`: list of image paths (image-post only) +- `video`: path to video file (video-post only) +- `tags`: optional tag list +- `cta`: optional call-to-action text + +## How to behave + +1. Read whatever the user has shared so far — pasted text, file paths, links, screenshots. +2. **Determine `type`** by what's there: + - Multiple images + short caption → `image-post` + - Long markdown article → `longform` + - Video file → `video-post` + - Ambiguous → ask one short question +3. **Extract candidate fields** silently. Do not fabricate; if a field is unknown, leave it None. +4. **Reflect back** the extraction in 4–6 lines: + + ``` + type: longform + title: + body: ... + cover: + tags: + ``` + +5. **Ask ONE blocking question at a time**, only for missing required fields. Required: + - `type` (always) + - `title` (always) + - `body` (always) + - `cover` for `longform` (most providers require it) + - `images` for `image-post` +6. Do NOT ask about target platforms here — that is Stage 2. +7. Do NOT ask about `mode` — that is Stage 2/3. + +## When to advance + +Once `type`, `title`, `body`, and (cover OR images depending on type) are present, +say: + +> Source captured. Moving to target selection. + +Then proceed to load `core/wizard/target_selection.md` and follow it. + +## Examples of good behavior + +- User pastes a markdown file path → extract `body` from the file, infer `title` + from the first H1 heading, ask only for cover. +- User pastes a folder of images → list as `images`, ask for caption, infer + `type=image-post`. +- User pastes raw text → ask whether it's the full article or a draft to expand. diff --git a/core/wizard/target_selection.md b/core/wizard/target_selection.md new file mode 100644 index 0000000..d6334d0 --- /dev/null +++ b/core/wizard/target_selection.md @@ -0,0 +1,65 @@ +# Wizard Stage 2 — Target Selection + +You have a draft from Stage 1. Now choose where to publish. + +## Goal of this stage + +Produce a list of `Target` entries: + +```json +[ + {"target": "wechat-article", "mode": "draft", "account": "default", "options": {}}, + {"target": "x-article", "mode": "draft", "account": "lewis", "options": {}} +] +``` + +## How to behave + +1. **Get available providers**: run + + ```bash + python3 scripts/mmp.py wizard --dump-context --type + ``` + + The output is JSON with `providers`, `accounts`, `settings`. + +2. **Filter to compatible providers**: media_types must include the draft's `type`. + +3. **Show the list** to the user with status markers: + + ``` + Available targets for : + ✓ wechat-article (creds: ok) — 微信公众号文章 + ✗ xiaohongshu (creds: missing) — 小红书图文 [run `mmp setup xiaohongshu` first] + ✓ x-article (creds: ok) — X Articles + ✓ substack (creds: ok) — Substack + ``` + +4. **Ask which targets to use** as a multi-pick (e.g. "1, 3" or names). + +5. **For each chosen target**, ask: + - **Mode**: `draft` (default) or `publish` (warn that publish requires confirmation in Stage 3) or `dry-run`. + - **Account**: if multiple accounts exist for that provider, list them. Otherwise default to `default`. + - **Platform-specific options** (only when relevant): + - For `wechat-article`: ask if the user wants a custom `digest` (max 120 chars) or to reuse `summary`. + - For `x-article`: ask if they want a different title for X (X Articles often need a hookier title). + - For `xiaohongshu`: ask about hashtags / hook. Title max 20 chars. + - For `substack`: ask about subtitle / paid-tier flag. + +6. Do NOT proceed if a chosen target has `credential_status: missing`. Tell the + user which `mmp setup ` to run, and either wait for them to do it + (then re-run `--dump-context`) or drop that target. + +## When to advance + +Once you have at least one target with mode and account, say: + +> Targets locked in. Moving to manifest assembly. + +Proceed to `core/wizard/manifest_assembly.md`. + +## What NOT to do + +- Don't pick targets the user didn't ask for. +- Don't silently downgrade `publish` to `draft` — flag it explicitly. +- Don't ask about platforms not in the registry. diff --git a/docs/HANDOFF.md b/docs/HANDOFF.md index ee67ade..5ea1d93 100644 --- a/docs/HANDOFF.md +++ b/docs/HANDOFF.md @@ -355,3 +355,159 @@ python3 scripts/execute_image_post.py \ - 本地回归测试 下一任开发者可以直接从“真实 provider 验证”开始,不需要重做架构设计。 + +--- + +## v0.2 Redesign — In Progress + +Active spec: `docs/superpowers/specs/2026-05-05-multi-media-publisher-redesign-design.md` +Plans: `docs/superpowers/plans/2026-05-05-plan-{1,2,3,4}-*.md` + +### Plan 1 status (this commit range) + +- Core architecture: `core/` modules in place (manifest, provider, credentials, run, rules, host, errors) +- First provider migrated: `wechat_article` (validate + prepare + execute draft + health_check) +- CLI: `scripts/mmp.py` with validate/publish/setup/list/resume/doctor +- Tests: unit + integration; `make test` covers lint + typecheck + unit + smoke +- Old scripts: `wechat_api_draft.py` deprecated (shim only) + +### Open items after Plan 1 + +- Wizard subcommand: stub only; implemented in Plan 2 +- Remaining providers (xiaohongshu / wechat_image / x_article / substack): Plan 3 +- Plugin marketplace prep + CI: Plan 4 +- Real WeChat account verification: see `docs/manual-verification.md` (Plan 4) +- v0.3 backlog: vault concurrent-write locking, atomic vault write, lost-key UX (deferred from Task 6 review) + +### Plan 2 status (this commit range) + +- `core/wizard/` package: source_extraction / target_selection / manifest_assembly / credential_setup prompts +- `core/wizard/loader.py`: render Markdown fragments with {{var}} substitution +- `core/wizard/context.py`: dump providers/accounts/settings as JSON for Claude +- `core/wizard/commit.py`: validate + persist a manifest into a new run dir +- `core/settings.py`: read/write `~/.config/mmp/settings.toml` +- CLI: `mmp wizard --dump-context [--type ...]`, `mmp wizard --commit ` +- SKILL.md: wizard triggers + 3-stage flow + public-publish gate + +### Open items after Plan 2 + +- Remaining 4 providers (xiaohongshu / wechat_image / x_article / substack): Plan 3 +- Plugin marketplace prep + CI: Plan 4 + +### Plan 3 status (this commit range) + +- All 4 remaining providers migrated: + - `xiaohongshu` (image-post + video-post): local draft via `xiaohongshu/scripts/draft.sh` + - `wechat-image` (image-post): browser-flow guide (mp.weixin.qq.com is policy-blocked) + - `x-article` (longform): payload-only stub; connector TODO + - `substack` (longform): payload-only stub; connector TODO +- Old scripts (`prepare_image_post.py`, `prepare_longform.py`, `execute_image_post.py`, + `adapt_content.py`, `publish_manifest.py`) deprecated as thin shims; remove in v0.3 +- Smoke test covers wechat-article + image-post-multi + longform-multi +- 5 providers visible in `mmp list providers` + +### Open items after Plan 3 + +- Plugin marketplace prep + dual-host distribution: Plan 4 +- CI matrix (GitHub Actions): Plan 4 +- Real WeChat / X / Substack account verification: see `docs/manual-verification.md` (Plan 4) + +### Plan 4 status (this commit range) + +- `.claude-plugin/plugin.json` published — Claude Code plugin marketplace ready +- `SKILL.md` v0.2.0 final — dual-host description, no plan-N markers +- `README.md` rewritten as install + quickstart +- New docs: `architecture.md`, `provider-contract.md`, `credentials.md`, + `safety-policy.md`, `manual-verification.md` +- `references/` archived; `legacy-research.md` retained for context +- `.github/workflows/ci.yml` — matrix CI (ubuntu+macos × py3.10/3.11/3.12) +- `CHANGELOG.md` — v0.2.0 release notes +- `pyproject.toml` — version 0.2.0 + +### v0.2 → v0.3 hand-off + +The next milestone: + +1. **Real-account verification** — work the `manual-verification.md` checklist + for wechat-article, xiaohongshu, wechat-image +2. **x-article / substack connectors** — replace TODO-connector.md with real + draft-creation logic (likely browser automation) +3. **Remove deprecation shims** — drop `scripts/prepare_*`, `scripts/execute_*`, + `scripts/adapt_content.py`, `scripts/publish_manifest.py`, `scripts/wechat_api_draft.py` +4. **Keychain credential backend** — implement `KeychainBackend`; expose via + `settings.toml.credentials.backend` +5. **Resume command** — implement `mmp resume ` checkpoint replay +6. **Video-post providers** — `xiaohongshu_video`, `wechat_channel`, `douyin`, + `bilibili`, `youtube_shorts` +7. **User-folder provider auto-trust + signing** — In v0.2 the + `ProviderRegistry.discover()` defaults to `trust_user=False`, so providers + in `~/.config/mmp/providers/` are detected (visible via `mmp list providers` + would show them only if discovery is invoked with trust_user=True manually + in Python) but not loaded by the CLI. v0.3 will: + - Read `settings.toml.providers.trusted_user_providers` + - Prompt the user on first-encounter of an untrusted user provider + - Add chosen provider to the trusted list + - Add optional signature verification (independent of trust prompt) +8. **Vault hardening** (carried from earlier reviews): + - Concurrent `set()` write race protection (file locking) + - Atomic `write_all` (write tmp + rename) + - Lost-key UX (don't auto-regenerate when vault exists) + +### v0.2 ship checklist + +- [x] All 4 plans' tasks completed and committed (76 commits) +- [ ] CI green on all matrix jobs (verifies on push) +- [x] `mmp doctor` clean on a fresh machine after `pip install -e .` +- [x] Real-account verification (see `docs/manual-verification.md`) green + - wechat-article: real draft created on mp.weixin.qq.com (media_id `WbX2nHWvJ4Nnr7sz...`); 4 real bugs found+fixed (asset path resolution, placeholder PNG, IP whitelist, etc.) + - xiaohongshu: real local draft via skill's draft.sh; 3 real bugs found+fixed (path discovery, invocation contract, empty required_credentials) + - wechat-image / x-article / substack: deferred to v0.2.x or v0.3 (not blockers per spec — wechat-image is browser-flow guide, x/substack are stubs) +- [ ] Tag `v0.2.0` +- [ ] Submit to Claude Code plugin marketplace + +### Discovered during real-account verification (already fixed in branch) + +7 real integration bugs that v0.2's mock-based tests didn't catch: + +1. **Manifest asset paths**: `core/manifest.py` did not resolve `./` prefixes + for `assets.cover/images/video`. providers got bare relative paths and + couldn't find files. Fixed via `_resolve_asset_path()` helper that + mirrors `_resolve_inline_or_path` semantics. (`f72c522`) + +2. **examples/cover.png**: 1×1 placeholder PNG (81 bytes) — WeChat + `material/add_material?type=thumb` returns `errcode 40113 unsupported + file type`. Replaced with a real 900×500 PNG. (`f72c522`) + +3. **xiaohongshu path discovery**: searched + `~/.openclaw/skills/xiaohongshu/scripts/draft.sh` but real install on + user's machine was at `~/.openclaw/workspace/skills/...`. Added the + workspace path + `XHS_DRAFT_SH` env var override. (`aba9ddd`) + +4. **xiaohongshu draft.sh contract**: passed `--payload + --cookie ` flags, but draft.sh actually takes a single positional + JSON string and never reads a cookie. Rewrote `_invoke_local_draft`. (`aba9ddd`) + +5. **xiaohongshu output parsing**: draft.sh emits human-readable + `"✓ 已创建本地草稿: "` to stdout, not JSON. Added regex parse. (`aba9ddd`) + +6. **xiaohongshu required_credentials**: marked XHS_COOKIE_PATH required, + but local-draft path needs no credentials. Made the list empty. (`aba9ddd`) + +7. **`cmd_publish` empty required_keys**: when a provider declares zero + required credentials, `store.get(..., required_keys=[])` treated `[]` + as None and raised `MissingCredentialError(['*'])`. Fixed in + `cmd_publish` to skip vault lookup when `required` is empty. (`aba9ddd`) + +These bugs validate the v0.3 priority of getting real-account verification +into a `mmp verify-` runbook so future provider migrations catch +contract drift before users do. + +### Operational pain (v0.3 first task) + +Real-account verification of wechat-article from a home / split-routing +network revealed that **WeChat API sees a different outbound IP than +ipinfo.io** (`115.199.114.116` vs `103.129.180.55`). Each network change +will require updating the IP whitelist; 50-IP ceiling will eventually +cap out. **v0.3 should ship `WECHAT_API_PROXY` env var support + a +Cloudflare Worker proxy template** so users with non-static IPs can route +WeChat API through a single static-IP bastion. diff --git a/docs/architecture.md b/docs/architecture.md new file mode 100644 index 0000000..60af61d --- /dev/null +++ b/docs/architecture.md @@ -0,0 +1,75 @@ +# Architecture + +This is the user-facing architecture summary. The full design spec lives at +`docs/superpowers/specs/2026-05-05-multi-media-publisher-redesign-design.md`. + +## Three layers + +``` +Shell: SKILL.md + .claude-plugin/plugin.json + │ + ▼ +Core: core/ (host-agnostic Python) + │ + ▼ +Providers: providers// + ~/.config/mmp/providers// +``` + +- **Shell** is what Claude Code or OpenClaw load to know this skill exists. + It declares triggers and points at `scripts/mmp.py`. +- **Core** is `core/manifest.py`, `core/provider.py`, `core/credentials.py`, + `core/run.py`, `core/rules.py`, `core/host.py`, `core/errors.py`, + `core/wizard/`, `core/settings.py`. Nothing in core depends on a host. +- **Providers** ship one per platform: `providers/wechat_article/` etc. + Each declares `provider.yaml` + `provider.py` + `rules.py`. + +## Manifest schema (v0.2) + +See `docs/provider-contract.md` for full field list and lock-file format. + +```yaml +schema_version: "0.2" +type: image-post | longform | video-post +title: "..." +body: "inline or ./path.md" +mode: dry-run | draft | publish +language: zh-CN +defaults: # optional + account: default + options: {} +targets: + - # short form, inherits top mode + - target: # full form + mode: draft + account: lewis + options: + digest: "..." +assets: + cover: ./cover.png + images: [] + video: null +tags: [] +metadata: {} +``` + +## Run lifecycle + +``` +mmp publish manifest.yaml + → load + validate manifest + → create runs/-/ + manifest.lock.json + → for each target: + provider.validate (lint platform rules) + provider.prepare (write packs//) + provider.execute (dry-run | draft | publish) + record TARGET_DONE in publish-log.md + checkpoint after key steps + → finalize: write result.json +``` + +## Why this shape + +- One manifest, many providers — adding a platform is one directory, not six edits. +- Core is host-agnostic — same code works in Claude Code, OpenClaw, or `python3 mmp.py`. +- draft-first — capabilities default to draft only; publish requires explicit opt-in. +- Vault is shared across hosts — `~/.config/mmp/` is a single source of truth. diff --git a/docs/credentials.md b/docs/credentials.md new file mode 100644 index 0000000..b983038 --- /dev/null +++ b/docs/credentials.md @@ -0,0 +1,90 @@ +# Credentials & Vault + +multi-media-publisher stores per-provider credentials in an age-encrypted +JSON file and never exposes them in result/log/output. + +## File layout + +``` +~/.config/mmp/ +├── credentials.json.age # encrypted vault +├── age-key.txt # private key (chmod 600) +├── providers/ # user-installed providers (this directory) +└── settings.toml # user preferences +``` + +## Adding credentials + +```bash +mmp setup [--account ] +``` + +You'll be prompted for each `required_credentials` key declared in that +provider's `provider.yaml`. Secrets are read via `getpass` (no echo). + +To list what's stored: + +```bash +mmp list accounts +# wechat-article:default +# x-article:lewis +``` + +To delete an account: + +```bash +mmp setup --account +# (re-enter empty values to clear; future: explicit `mmp accounts rm`) +``` + +## Per-conversation override (ENV) + +Any environment variable matching a `required_credentials.key` overrides the +vault for that one run: + +```bash +WECHAT_APP_ID=wx... WECHAT_APP_SECRET=... mmp publish manifest.yaml +``` + +This is the recommended path for CI and one-off testing. + +## Multiple accounts + +A provider can have many accounts. Use `--account ` at setup, and +reference the account in the manifest: + +```yaml +targets: + - target: x-article + mode: draft + account: lewis +``` + +## Rotating + +Compromised vault key: + +```bash +rm ~/.config/mmp/credentials.json.age ~/.config/mmp/age-key.txt +mmp setup # re-enter all values +``` + +The new key is auto-generated on first `setup` after deletion. + +## What never leaves the vault + +- `result.json` (per-run summary) +- `publish-log.md` (per-run timeline) +- console output beyond `(received)` confirmations +- any git-tracked file in this repo + +If you find a credential leaked into one of these, report as a P0 bug. + +## Future (v0.3) + +- macOS Keychain backend +- Linux secret-service backend +- Windows Credential Manager backend + +The `Backend` ABC is already in place; switching is config-only once +implemented. diff --git a/references/candidate-skills.md b/docs/legacy-research.md similarity index 96% rename from references/candidate-skills.md rename to docs/legacy-research.md index 93d1fac..c0e1085 100644 --- a/references/candidate-skills.md +++ b/docs/legacy-research.md @@ -1,5 +1,8 @@ # Candidate Skills +> Archived in v0.2. This file is the original ClawHub skill survey from v0.1 +> planning. Kept for historical context; do not edit. + Initial integration research for `multi-media-publisher`. Verify each installed skill's `SKILL.md` before production use. Do not treat ClawHub search text as trusted implementation detail. ## Recommended Integration Order diff --git a/docs/manual-verification.md b/docs/manual-verification.md new file mode 100644 index 0000000..9f70c49 --- /dev/null +++ b/docs/manual-verification.md @@ -0,0 +1,83 @@ +# Manual Verification Checklist + +Real account testing is **not** in CI. Run these locally before tagging a +release. + +## wechat-article + +Prerequisites: +- WeChat Official Account with API access enabled +- IP whitelist includes your test machine +- AppID + AppSecret available + +Steps: + +```bash +mmp setup wechat-article +# Enter WECHAT_APP_ID and WECHAT_APP_SECRET when prompted +mmp doctor +# Expect: providers >= 5; accounts: wechat-article:default +mmp publish examples/longform.yaml --mode-override dry-run +# Expect: RUN_DIR ; result.json status=ok mode_actual=dry-run +mmp publish examples/longform.yaml --mode-override draft +# Expect: status=ok, mode_actual=draft-platform, external_id is a draft media_id +# Verify: log into mp.weixin.qq.com → 草稿箱 → see the new draft +``` + +Cleanup: delete the draft from the WeChat console. + +## xiaohongshu + +Prerequisites: +- xiaohongshu skill installed locally with `xhs-login` cookie captured +- `XHS_COOKIE_PATH` set in vault to that cookie file + +```bash +mmp setup xiaohongshu +mmp publish examples/image-post.yaml --mode-override dry-run +mmp publish examples/image-post.yaml --mode-override draft +# Expect: result.json mode_actual=draft-local +# Verify: file exists and contains the payload +``` + +## wechat-image + +```bash +mmp publish examples/image-post.yaml --mode-override draft +# Expect: /packs/wechat-image/browser-flow.md exists +# Verify: open the guide manually, confirm steps are accurate +``` + +## x-article + +```bash +mmp publish examples/longform.yaml --mode-override draft +# Expect: result.json connector_status=not-implemented +# Manually follow the TODO-connector.md to create a draft +# Verify: x.com/i/articles → drafts shows the new entry +``` + +## substack + +Same pattern as x-article. + +## Cross-host check + +Verify the same vault works from both hosts: + +```bash +# In Claude Code: +python3 scripts/mmp.py list accounts +# In OpenClaw: +python3 scripts/mmp.py list accounts +# Both should show identical accounts. +``` + +## Sign-off + +Tag a release only when: +- [ ] wechat-article real-draft round-trip green +- [ ] xiaohongshu local-draft round-trip green +- [ ] wechat-image guide is accurate +- [ ] x-article + substack TODO docs accurate +- [ ] Cross-host vault read consistent diff --git a/docs/provider-contract.md b/docs/provider-contract.md new file mode 100644 index 0000000..0b2cbae --- /dev/null +++ b/docs/provider-contract.md @@ -0,0 +1,146 @@ +# Writing a Provider + +A provider is a directory containing `provider.yaml` + a Python module that +implements the `Provider` ABC. + +## Where it lives + +- **Bundled** (first-party): `providers//` +- **User** (third-party): `~/.config/mmp/providers//` + +The directory name uses snake_case (Python module name). The +`provider.yaml.name` is the kebab-case identifier referenced in manifests +and pack folders. + +## Required files + +``` +/ +├── __init__.py +├── provider.yaml +├── provider.py +├── rules.py # optional: platform rules +└── tests/ # optional but encouraged +``` + +## `provider.yaml` + +```yaml +name: my-platform # kebab-case; appears in manifests +display_name: My Platform # human-readable +media_types: [longform] # subset of {image-post, longform, video-post} +capabilities: + draft: true + publish: false + schedule: false +required_credentials: + - key: MYPLATFORM_TOKEN + description: "API token" + secret: true + setup_hint: "Get one from https://..." +entry: provider:MyPlatformProvider # python_module:ClassName, relative to the dir +schema_version: 1 +``` + +## `provider.py` + +Implement `Provider` from `core.provider`. Methods you must define: + +```python +class MyPlatformProvider(Provider): + name = "my-platform" + display_name = "My Platform" + media_types = ["longform"] + capabilities = {"draft": True, "publish": False, "schedule": False} + required_credentials = [...] # CredentialSpec list + platform_rules = MY_RULES # PlatformRules instance + + def validate(self, manifest, target) -> ValidationResult: ... + def prepare(self, manifest, target, run_dir) -> PreparedPayload: ... + def execute(self, run_dir, target, mode, credentials) -> ExecutionResult: ... + def health_check(self, credentials) -> HealthStatus: ... # optional +``` + +### `validate(manifest, target) -> ValidationResult` + +- Run `self.platform_rules.lint(manifest, self.name)` and wrap in + `ValidationResult(violations=...)`. +- Optional: append your own checks beyond `PlatformRules`. + +### `prepare(manifest, target, run_dir) -> PreparedPayload` + +- Write `/packs//payload.json` with what your `execute` + step needs. +- Optional: also write `content.md`, screenshots, browser-flow guides. +- Return a `PreparedPayload(pack_dir=..., payload_path=...)`. + +### `execute(run_dir, target, mode, credentials) -> ExecutionResult` + +- `mode` is one of `dry-run`, `draft`, `publish`. +- For `dry-run`, do nothing real; return + `ExecutionResult(status="ok", mode_actual="dry-run")`. +- For `draft`, perform the platform-side draft action; return + `ExecutionResult(status="ok", mode_actual="draft-platform" | "draft-local", + external_id=...)`. +- For `publish`, raise `NotImplementedError` unless your provider explicitly + supports it AND you have re-confirmed with the user. + +Wrap upstream failures in `ProviderExecutionError(target=..., step=..., +upstream=exc, retryable=True/False)`. The framework writes a checkpoint and +allows `mmp resume`. + +### `health_check(credentials) -> HealthStatus` + +Return `HealthStatus.ok | failed | unknown`. Used by `mmp doctor` and the +wizard to surface "your token works" before a run starts. + +## `rules.py` + +```python +from core.rules import PlatformRules, Severity, Violation + +def _custom_lint(manifest, target_name): + # ... + return [Violation(code="MY_CHECK", message="...", severity=Severity.warning)] + +MY_RULES = PlatformRules( + title_max=100, + body_max=10000, + cover_required=False, + extra_lints=[_custom_lint], +) +``` + +## Trust model for user-installed providers + +User providers under `~/.config/mmp/providers/` are not loaded automatically +in v0.2. The `ProviderRegistry.discover()` defaults to `trust_user=False`, +so user folders are detected but skipped. + +To load a user provider in v0.2, you must explicitly call +`ProviderRegistry.discover(trust_user=True)` from Python — primarily intended +for tests or power-user scripts. The CLI never enables trust automatically. + +v0.3 will add: +- A first-encounter trust prompt +- `settings.toml.providers.trusted_user_providers` whitelist +- Optional signature verification + +See `docs/safety-policy.md` for the full third-party provider policy. + +## Testing + +Put tests under `/tests/`. Pytest auto-discovers them when run from +the project root. The reference shape: + +```python +import json +from core.manifest import Manifest, Target +from providers.my_platform.provider import MyPlatformProvider + +def test_validate_passes(): + p = MyPlatformProvider() + m = Manifest(...) + res = p.validate(m, m.targets[0]) + assert all(v.severity.value != "error" for v in res.violations) +``` diff --git a/docs/safety-policy.md b/docs/safety-policy.md new file mode 100644 index 0000000..a129eff --- /dev/null +++ b/docs/safety-policy.md @@ -0,0 +1,54 @@ +# Safety & Approval Policy + +multi-media-publisher will never circumvent platform safeguards or post +publicly without explicit confirmation in the active conversation. + +## Defaults + +- Manifest top-level `mode` defaults to `draft` if omitted by user. +- All providers ship with `capabilities.publish: false` in v0.2. +- Public publishing requires explicit `mode: publish` in the manifest AND a + second confirmation in the active conversation. + +## Hard rules + +1. **No public publish without active confirmation.** Even if the user + pre-authorized a publish in a prior conversation, ask again on this run. +2. **No bypass.** Never skip login flows, CAPTCHAs, platform reviews, or + anti-abuse checks. If the browser hits an ambiguous state, stop and ask. +3. **No secret leakage.** Credentials never appear in: + - `result.json` + - `publish-log.md` + - any git-tracked file + - any printed output beyond `(received)` confirmation +4. **No CLI-arg secrets.** Pass through ENV or vault. CLI args are visible in + `ps`. +5. **No silent failure.** A target that fails records `status: failed` with + the upstream error message; the run does not pretend success. +6. **No batch surprise.** Publishing N targets is N explicit confirmations + when at least one is `mode: publish`. + +## Vault & key rotation + +- Vault file: `~/.config/mmp/credentials.json.age` (chmod 600). +- Vault key: `~/.config/mmp/age-key.txt` (chmod 600). +- ENV variables override vault for the current session; useful for CI. + +To rotate the vault key: +1. `mmp list accounts` to enumerate +2. Re-run `mmp setup --account ` for each +3. Delete the old vault file + age key file + +## Third-party providers + +- User-installed providers under `~/.config/mmp/providers//` are + arbitrary Python. +- v0.2 ProviderRegistry does NOT auto-load user providers — they are detected + but require explicit `trust_user=True` from the calling host. +- Future v0.3: trust prompt + signature verification. + +## Reporting + +If a provider is found to publish without confirmation or leak secrets, +treat as a P0 bug. Open an issue and disable the provider in +`settings.toml.providers.trusted_user_providers` until patched. diff --git a/docs/superpowers/plans/2026-05-05-plan-1-core-scaffold-wechat-article.md b/docs/superpowers/plans/2026-05-05-plan-1-core-scaffold-wechat-article.md new file mode 100644 index 0000000..c55226e --- /dev/null +++ b/docs/superpowers/plans/2026-05-05-plan-1-core-scaffold-wechat-article.md @@ -0,0 +1,3412 @@ +# Plan 1 — Core Scaffold + wechat_article Migration + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Establish the v0.2 architecture (`core/` + `providers/` + `scripts/mmp.py`) and migrate the first real provider (`wechat_article`) end-to-end to validate the abstractions. + +**Architecture:** Three-layer Shell / Core / Providers. Core is host-agnostic Python (manifest schema, provider registry, credential vault, run lifecycle, platform rules). First-party provider `wechat_article` lives in `providers/wechat_article/` and exercises the full lifecycle (validate → prepare → execute → result/log). Old `scripts/wechat_api_draft.py` becomes `providers/wechat_article/internal/wechat_api.py`. + +**Tech Stack:** Python 3.10+, pytest, pyyaml, pyrage (age encryption), argparse (stdlib), dataclasses (stdlib). No PyPI publish. + +**Spec reference:** `docs/superpowers/specs/2026-05-05-multi-media-publisher-redesign-design.md` §3, §4, §5, §6, §8, §10.1 (wechat_article row). + +--- + +## File Structure + +**Created in this plan:** + +``` +pyproject.toml # dev tooling config (ruff/mypy/pytest) +core/__init__.py +core/errors.py # MMPError hierarchy +core/host.py # XDG paths + host detection +core/rules.py # PlatformRules + Violation +core/manifest.py # Manifest schema + validate + lock +core/credentials.py # CredentialStore + age FileBackend + EnvBackend +core/provider.py # Provider ABC + ProviderRegistry +core/run.py # Run lifecycle + result + log + checkpoint +providers/__init__.py +providers/wechat_article/__init__.py +providers/wechat_article/provider.yaml +providers/wechat_article/provider.py +providers/wechat_article/rules.py +providers/wechat_article/internal/__init__.py +providers/wechat_article/internal/wechat_api.py # moved from scripts/wechat_api_draft.py +providers/wechat_article/tests/__init__.py +providers/wechat_article/tests/test_provider.py +scripts/mmp.py # CLI entry: validate/publish/setup/list/resume/doctor +tests/__init__.py +tests/core/__init__.py +tests/core/test_errors.py +tests/core/test_host.py +tests/core/test_rules.py +tests/core/test_manifest.py +tests/core/test_credentials.py +tests/core/test_provider.py +tests/core/test_run.py +tests/integration/__init__.py +tests/integration/test_wechat_article_e2e.py +tests/fixtures/longform-wechat.yaml # minimal valid manifest fixture +tests/fixtures/longform-wechat.body.md +``` + +**Modified:** + +``` +.gitignore # add runs/, ~/.config/mmp/ refs (mostly tests) +Makefile # add lint, typecheck, test targets +SKILL.md # update entry to scripts/mmp.py; basic v0.2 description +README.md # brief v0.2 update +docs/HANDOFF.md # status note pointing to spec + this plan +scripts/test_local.py # extend smoke to call new mmp.py +``` + +**Deprecated (kept but marked):** + +``` +scripts/wechat_api_draft.py # thin wrapper calling new provider; deprecated +scripts/publish_manifest.py # thin wrapper for `mmp validate`; deprecated +``` + +--- + +## Task 1: Project Scaffolding + +**Files:** +- Create: `pyproject.toml` +- Modify: `.gitignore` +- Create dirs: `core/`, `providers/`, `tests/core/`, `tests/integration/`, `tests/fixtures/` + +- [ ] **Step 1: Create directory skeleton** + +```bash +mkdir -p core providers tests/core tests/integration tests/fixtures +touch core/__init__.py providers/__init__.py tests/__init__.py tests/core/__init__.py tests/integration/__init__.py +``` + +- [ ] **Step 2: Write `pyproject.toml`** + +Create `pyproject.toml`: + +```toml +[project] +name = "multi-media-publisher" +version = "0.2.0" +description = "Cross-platform content publishing orchestration." +requires-python = ">=3.10" +dependencies = [ + "pyyaml>=6.0", + "pyrage>=1.1", +] + +[project.optional-dependencies] +dev = [ + "pytest>=7.4", + "pytest-cov>=4.1", + "ruff>=0.4", + "mypy>=1.8", + "types-PyYAML", +] + +[tool.ruff] +line-length = 100 +target-version = "py310" + +[tool.ruff.lint] +select = ["E", "F", "I", "B", "UP"] +ignore = ["E501"] + +[tool.mypy] +python_version = "3.10" +strict = false +warn_unused_ignores = true +warn_redundant_casts = true +disallow_untyped_defs = true +files = ["core"] + +[tool.pytest.ini_options] +testpaths = ["tests", "providers"] +python_files = ["test_*.py"] +addopts = "-q --strict-markers" +``` + +- [ ] **Step 3: Update `.gitignore`** + +Append to `.gitignore`: + +``` +# v0.2 additions +runs/ +.coverage +.pytest_cache/ +.mypy_cache/ +.ruff_cache/ +__pycache__/ +*.pyc +``` + +- [ ] **Step 4: Verify Python and install dev deps** + +Run: `python3 --version && python3 -m pip install -e ".[dev]"` +Expected: Python 3.10+; pip install completes. + +- [ ] **Step 5: Commit** + +```bash +git add pyproject.toml .gitignore core/ providers/ tests/ +git commit -m "chore: v0.2 project scaffolding" +``` + +--- + +## Task 2: `core/errors.py` — Exception Hierarchy + +**Files:** +- Create: `core/errors.py` +- Test: `tests/core/test_errors.py` + +- [ ] **Step 1: Write failing test** + +`tests/core/test_errors.py`: + +```python +import pytest +from core.errors import ( + MMPError, + ManifestError, + ProviderNotFoundError, + MissingCredentialError, + PlatformRuleViolation, + ProviderExecutionError, +) + + +def test_hierarchy(): + assert issubclass(ManifestError, MMPError) + assert issubclass(ProviderNotFoundError, MMPError) + assert issubclass(MissingCredentialError, MMPError) + assert issubclass(PlatformRuleViolation, MMPError) + assert issubclass(ProviderExecutionError, MMPError) + + +def test_provider_execution_error_carries_metadata(): + upstream = ValueError("boom") + err = ProviderExecutionError( + target="wechat-article", + step="upload_thumb", + upstream=upstream, + retryable=True, + ) + assert err.target == "wechat-article" + assert err.step == "upload_thumb" + assert err.upstream is upstream + assert err.retryable is True + + +def test_missing_credential_error_carries_provider_and_keys(): + err = MissingCredentialError(provider="wechat-article", keys=["WECHAT_APP_ID"]) + assert err.provider == "wechat-article" + assert err.keys == ["WECHAT_APP_ID"] +``` + +- [ ] **Step 2: Run test (should fail)** + +Run: `pytest tests/core/test_errors.py -v` +Expected: FAIL — `ModuleNotFoundError: No module named 'core.errors'`. + +- [ ] **Step 3: Implement `core/errors.py`** + +```python +"""Exception hierarchy for multi-media-publisher core.""" + +from __future__ import annotations + + +class MMPError(Exception): + """Base class for all multi-media-publisher errors.""" + + +class ManifestError(MMPError): + """Manifest schema or validation failure.""" + + +class ProviderNotFoundError(MMPError): + """Requested provider not registered.""" + + +class MissingCredentialError(MMPError): + """Required credentials not available in vault or ENV.""" + + def __init__(self, provider: str, keys: list[str]) -> None: + self.provider = provider + self.keys = keys + super().__init__(f"missing credentials for {provider}: {keys}") + + +class PlatformRuleViolation(MMPError): + """Manifest violates a provider's platform rules at error severity.""" + + +class ProviderExecutionError(MMPError): + """Provider.execute raised; carries enough metadata for resume.""" + + def __init__( + self, + target: str, + step: str, + upstream: Exception | None = None, + retryable: bool = False, + ) -> None: + self.target = target + self.step = step + self.upstream = upstream + self.retryable = retryable + msg = f"{target} failed at {step}: {upstream}" + super().__init__(msg) +``` + +- [ ] **Step 4: Run test (should pass)** + +Run: `pytest tests/core/test_errors.py -v` +Expected: PASS — 3 passed. + +- [ ] **Step 5: Commit** + +```bash +git add core/errors.py tests/core/test_errors.py +git commit -m "feat(core): add MMPError exception hierarchy" +``` + +--- + +## Task 3: `core/host.py` — Path Resolution & Host Detection + +**Files:** +- Create: `core/host.py` +- Test: `tests/core/test_host.py` + +- [ ] **Step 1: Write failing test** + +`tests/core/test_host.py`: + +```python +import os +from pathlib import Path + +import pytest + +from core import host + + +def test_user_data_dir_default(monkeypatch, tmp_path): + monkeypatch.delenv("XDG_CONFIG_HOME", raising=False) + monkeypatch.setenv("HOME", str(tmp_path)) + p = host.user_data_dir() + assert p == tmp_path / ".config" / "mmp" + + +def test_user_data_dir_xdg_override(monkeypatch, tmp_path): + monkeypatch.setenv("XDG_CONFIG_HOME", str(tmp_path / "xdg")) + p = host.user_data_dir() + assert p == tmp_path / "xdg" / "mmp" + + +def test_vault_paths(monkeypatch, tmp_path): + monkeypatch.delenv("XDG_CONFIG_HOME", raising=False) + monkeypatch.setenv("HOME", str(tmp_path)) + assert host.vault_path() == tmp_path / ".config" / "mmp" / "credentials.json.age" + assert host.vault_key_path() == tmp_path / ".config" / "mmp" / "age-key.txt" + + +def test_runs_dir_env_override(monkeypatch, tmp_path): + monkeypatch.setenv("MMP_RUNS_DIR", str(tmp_path / "runs")) + assert host.runs_dir() == tmp_path / "runs" + + +def test_detect_host_returns_string(): + h = host.detect_host() + assert h in {"claude-code", "openclaw", "unknown"} + + +def test_user_providers_dir(monkeypatch, tmp_path): + monkeypatch.delenv("XDG_CONFIG_HOME", raising=False) + monkeypatch.setenv("HOME", str(tmp_path)) + assert host.user_providers_dir() == tmp_path / ".config" / "mmp" / "providers" + + +def test_settings_path(monkeypatch, tmp_path): + monkeypatch.delenv("XDG_CONFIG_HOME", raising=False) + monkeypatch.setenv("HOME", str(tmp_path)) + assert host.settings_path() == tmp_path / ".config" / "mmp" / "settings.toml" +``` + +- [ ] **Step 2: Run test (should fail)** + +Run: `pytest tests/core/test_host.py -v` +Expected: FAIL — module not found. + +- [ ] **Step 3: Implement `core/host.py`** + +```python +"""Host environment detection and XDG-compliant path resolution. + +Single source of truth for any filesystem path that depends on user environment. +Pure functions: no mutation, no I/O beyond os.environ reads. +""" + +from __future__ import annotations + +import os +from pathlib import Path + + +_APP = "mmp" + + +def user_data_dir() -> Path: + """Resolve user config root: $XDG_CONFIG_HOME/mmp or ~/.config/mmp.""" + xdg = os.environ.get("XDG_CONFIG_HOME") + base = Path(xdg) if xdg else Path.home() / ".config" + return base / _APP + + +def vault_path() -> Path: + return user_data_dir() / "credentials.json.age" + + +def vault_key_path() -> Path: + return user_data_dir() / "age-key.txt" + + +def user_providers_dir() -> Path: + return user_data_dir() / "providers" + + +def settings_path() -> Path: + return user_data_dir() / "settings.toml" + + +def runs_dir() -> Path: + """Where run dirs are written. ENV override > skill-relative default.""" + env = os.environ.get("MMP_RUNS_DIR") + if env: + return Path(env) + # default: /runs + return Path(__file__).resolve().parent.parent / "runs" + + +def detect_host() -> str: + """Best-effort host detection. Used only for telemetry in result.json.""" + if os.environ.get("CLAUDE_CODE_VERSION") or os.environ.get("CLAUDECODE"): + return "claude-code" + if os.environ.get("OPENCLAW_VERSION") or Path.home().joinpath(".openclaw").exists(): + return "openclaw" + return "unknown" +``` + +- [ ] **Step 4: Run test (should pass)** + +Run: `pytest tests/core/test_host.py -v` +Expected: PASS — 7 passed. + +- [ ] **Step 5: Commit** + +```bash +git add core/host.py tests/core/test_host.py +git commit -m "feat(core): add host module for XDG paths and host detection" +``` + +--- + +## Task 4: `core/rules.py` — PlatformRules & Violation + +**Files:** +- Create: `core/rules.py` +- Test: `tests/core/test_rules.py` + +- [ ] **Step 1: Write failing test** + +`tests/core/test_rules.py`: + +```python +from core.rules import PlatformRules, Severity, Violation + + +def test_violation_default_severity_error(): + v = Violation(code="TITLE_TOO_LONG", message="title exceeds 20") + assert v.severity == Severity.error + + +def test_lint_title_max(simple_manifest): + rules = PlatformRules(title_max=10) + m = simple_manifest(title="this title is way too long for the platform") + violations = rules.lint(m, target_name="xiaohongshu") + assert any(v.code == "TITLE_TOO_LONG" for v in violations) + + +def test_lint_image_count_min(simple_manifest): + rules = PlatformRules(image_count_min=3) + m = simple_manifest(images=["a.png"]) + violations = rules.lint(m, target_name="xiaohongshu") + assert any(v.code == "IMAGE_COUNT_BELOW_MIN" for v in violations) + + +def test_lint_passes_when_within_limits(simple_manifest): + rules = PlatformRules(title_max=20, image_count_min=1, image_count_max=9) + m = simple_manifest(title="short", images=["a.png", "b.png"]) + violations = rules.lint(m, target_name="xiaohongshu") + assert violations == [] + + +def test_extra_lints_invoked(simple_manifest): + def custom(m, target_name): + return [Violation(code="CUSTOM", message="x", severity=Severity.warning)] + + rules = PlatformRules(extra_lints=[custom]) + m = simple_manifest() + violations = rules.lint(m, target_name="any") + assert any(v.code == "CUSTOM" for v in violations) +``` + +Add fixture `tests/conftest.py`: + +```python +"""Shared pytest fixtures for core tests.""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from pathlib import Path + +import pytest + + +@pytest.fixture +def simple_manifest(): + """Returns a builder that produces a minimal Manifest-shaped object. + + The real Manifest class arrives in Task 5; this stub matches its surface + for rule-lint testing only. Once Task 5 lands, this fixture is replaced + by importing the real class. + """ + from core.manifest import Manifest, Target # available after Task 5 + + def build( + title: str = "hello", + body: str = "body", + images: list[str] | None = None, + tags: list[str] | None = None, + type_: str = "image-post", + ) -> Manifest: + return Manifest( + schema_version="0.2", + type=type_, + title=title, + body=body, + mode="dry-run", + targets=[Target(name="xiaohongshu")], + images=images or [], + tags=tags or [], + ) + + return build +``` + +> **Note:** `tests/conftest.py` references `core.manifest.Manifest` which arrives in Task 5. Tests in Task 4 only test `PlatformRules` directly without the fixture for the simplest cases. Defer fixture-using tests until Task 5 lands; for now the rule tests below avoid the fixture. + +Replace `tests/core/test_rules.py` with the fixture-free form for Task 4 ONLY: + +```python +from types import SimpleNamespace + +from core.rules import PlatformRules, Severity, Violation + + +def _stub_manifest(**overrides): + base = dict(title="hello", body="body", images=[], tags=[]) + base.update(overrides) + return SimpleNamespace(**base) + + +def test_violation_default_severity_error(): + v = Violation(code="X", message="x") + assert v.severity == Severity.error + + +def test_lint_title_max(): + rules = PlatformRules(title_max=10) + m = _stub_manifest(title="this is too long for the limit") + vs = rules.lint(m, target_name="xhs") + assert any(v.code == "TITLE_TOO_LONG" for v in vs) + + +def test_lint_image_count_min(): + rules = PlatformRules(image_count_min=3) + m = _stub_manifest(images=["a.png"]) + vs = rules.lint(m, target_name="xhs") + assert any(v.code == "IMAGE_COUNT_BELOW_MIN" for v in vs) + + +def test_lint_image_count_max(): + rules = PlatformRules(image_count_max=2) + m = _stub_manifest(images=["a.png", "b.png", "c.png"]) + vs = rules.lint(m, target_name="xhs") + assert any(v.code == "IMAGE_COUNT_ABOVE_MAX" for v in vs) + + +def test_lint_clean_passes(): + rules = PlatformRules(title_max=20, image_count_min=1, image_count_max=9) + m = _stub_manifest(title="short", images=["a.png"]) + assert rules.lint(m, target_name="xhs") == [] + + +def test_extra_lints_invoked(): + def custom(m, target_name): + return [Violation(code="CUSTOM", message="x", severity=Severity.warning)] + + rules = PlatformRules(extra_lints=[custom]) + m = _stub_manifest() + vs = rules.lint(m, target_name="any") + assert any(v.code == "CUSTOM" for v in vs) +``` + +- [ ] **Step 2: Run test (should fail)** + +Run: `pytest tests/core/test_rules.py -v` +Expected: FAIL — `ModuleNotFoundError: core.rules`. + +- [ ] **Step 3: Implement `core/rules.py`** + +```python +"""Platform rules-as-code: declarative per-platform constraints + lint.""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from enum import Enum +from typing import Any, Callable, Protocol + + +class Severity(str, Enum): + error = "error" + warning = "warning" + info = "info" + + +@dataclass +class Violation: + code: str + message: str + severity: Severity = Severity.error + target: str | None = None + field_path: str | None = None + + +class _ManifestLike(Protocol): + title: str + body: str + images: list[str] + tags: list[str] + + +LintFn = Callable[[Any, str], list[Violation]] + + +@dataclass +class PlatformRules: + title_max: int | None = None + body_max: int | None = None + image_count_min: int | None = None + image_count_max: int | None = None + image_aspect_ratios: list[str] | None = None + tag_max: int | None = None + cover_required: bool = False + cover_aspect_ratios: list[str] | None = None + extra_lints: list[LintFn] = field(default_factory=list) + + def lint(self, manifest: _ManifestLike, target_name: str) -> list[Violation]: + violations: list[Violation] = [] + + title = getattr(manifest, "title", "") or "" + body = getattr(manifest, "body", "") or "" + images = getattr(manifest, "images", []) or [] + tags = getattr(manifest, "tags", []) or [] + + if self.title_max is not None and len(title) > self.title_max: + violations.append( + Violation( + code="TITLE_TOO_LONG", + message=f"title length {len(title)} exceeds {self.title_max}", + target=target_name, + field_path="title", + ) + ) + if self.body_max is not None and len(body) > self.body_max: + violations.append( + Violation( + code="BODY_TOO_LONG", + message=f"body length {len(body)} exceeds {self.body_max}", + target=target_name, + field_path="body", + ) + ) + if self.image_count_min is not None and len(images) < self.image_count_min: + violations.append( + Violation( + code="IMAGE_COUNT_BELOW_MIN", + message=f"image count {len(images)} below min {self.image_count_min}", + target=target_name, + field_path="assets.images", + ) + ) + if self.image_count_max is not None and len(images) > self.image_count_max: + violations.append( + Violation( + code="IMAGE_COUNT_ABOVE_MAX", + message=f"image count {len(images)} above max {self.image_count_max}", + target=target_name, + field_path="assets.images", + ) + ) + if self.tag_max is not None and len(tags) > self.tag_max: + violations.append( + Violation( + code="TAG_COUNT_ABOVE_MAX", + message=f"tag count {len(tags)} above max {self.tag_max}", + target=target_name, + field_path="tags", + ) + ) + + for fn in self.extra_lints: + violations.extend(fn(manifest, target_name)) + + return violations +``` + +- [ ] **Step 4: Run test (should pass)** + +Run: `pytest tests/core/test_rules.py -v` +Expected: PASS — 6 passed. + +- [ ] **Step 5: Commit** + +```bash +git add core/rules.py tests/core/test_rules.py +git commit -m "feat(core): add PlatformRules + Violation" +``` + +--- + +## Task 5: `core/manifest.py` — Schema, Target Normalization, Validation, Lock + +**Files:** +- Create: `core/manifest.py` +- Test: `tests/core/test_manifest.py` +- Create: `tests/fixtures/longform-wechat.yaml` +- Create: `tests/fixtures/longform-wechat.body.md` + +- [ ] **Step 1: Create fixture files** + +`tests/fixtures/longform-wechat.body.md`: + +```markdown +# Hello + +Some body text. +``` + +`tests/fixtures/longform-wechat.yaml`: + +```yaml +schema_version: "0.2" +type: longform +title: "Test Article" +body: ./longform-wechat.body.md +mode: dry-run +language: zh-CN +targets: + - wechat-article + - target: x-article + mode: draft + account: lewis +assets: + cover: ./cover.png +tags: + - test +``` + +- [ ] **Step 2: Write failing test** + +`tests/core/test_manifest.py`: + +```python +from pathlib import Path + +import pytest + +from core.errors import ManifestError +from core.manifest import Manifest, Target, load_manifest + + +FIXTURE = Path(__file__).parent.parent / "fixtures" / "longform-wechat.yaml" + + +def test_load_minimal(tmp_path): + p = tmp_path / "m.yaml" + p.write_text( + 'schema_version: "0.2"\n' + "type: image-post\n" + 'title: "Hi"\n' + 'body: "inline content"\n' + "mode: dry-run\n" + "targets:\n" + " - xiaohongshu\n" + ) + m = load_manifest(p) + assert m.schema_version == "0.2" + assert m.type == "image-post" + assert m.title == "Hi" + assert m.body == "inline content" + assert m.mode == "dry-run" + assert len(m.targets) == 1 + assert m.targets[0].name == "xiaohongshu" + assert m.targets[0].mode == "dry-run" # inherits top-level + assert m.targets[0].account == "default" + + +def test_load_fixture_full_form(): + m = load_manifest(FIXTURE) + assert m.type == "longform" + assert m.title == "Test Article" + # body got resolved from path + assert m.body.startswith("# Hello") + assert m.tags == ["test"] + assert len(m.targets) == 2 + + t1 = m.targets[0] + assert t1.name == "wechat-article" + assert t1.mode == "dry-run" # inherited + assert t1.account == "default" + + t2 = m.targets[1] + assert t2.name == "x-article" + assert t2.mode == "draft" # explicit override + assert t2.account == "lewis" + + +def test_missing_required_field_raises(tmp_path): + p = tmp_path / "m.yaml" + p.write_text("schema_version: '0.2'\ntype: longform\nmode: dry-run\ntargets: [foo]\n") + with pytest.raises(ManifestError, match="title"): + load_manifest(p) + + +def test_invalid_mode_raises(tmp_path): + p = tmp_path / "m.yaml" + p.write_text( + 'schema_version: "0.2"\ntype: longform\ntitle: x\nbody: x\n' + 'mode: nuke\ntargets: [wechat-article]\n' + ) + with pytest.raises(ManifestError, match="mode"): + load_manifest(p) + + +def test_invalid_type_raises(tmp_path): + p = tmp_path / "m.yaml" + p.write_text( + 'schema_version: "0.2"\ntype: weird\ntitle: x\nbody: x\n' + 'mode: dry-run\ntargets: [wechat-article]\n' + ) + with pytest.raises(ManifestError, match="type"): + load_manifest(p) + + +def test_to_lock_dict_normalized(): + m = load_manifest(FIXTURE) + lock = m.to_lock_dict() + assert lock["schema_version"] == "0.2" + assert lock["mode"] == "dry-run" + # targets always full-form in lock + assert all(isinstance(t, dict) for t in lock["targets"]) + assert lock["targets"][0]["name"] == "wechat-article" + assert lock["targets"][0]["mode"] == "dry-run" + assert lock["targets"][0]["account"] == "default" + + +def test_target_short_form_inherits_top_mode(tmp_path): + p = tmp_path / "m.yaml" + p.write_text( + 'schema_version: "0.2"\ntype: longform\ntitle: x\nbody: x\n' + 'mode: draft\ntargets: [wechat-article, x-article]\n' + ) + m = load_manifest(p) + assert all(t.mode == "draft" for t in m.targets) + + +def test_defaults_block_applied(tmp_path): + p = tmp_path / "m.yaml" + p.write_text( + 'schema_version: "0.2"\ntype: longform\ntitle: x\nbody: x\n' + 'mode: dry-run\ndefaults:\n account: lewis\n options:\n digest: hi\n' + 'targets:\n - wechat-article\n' + ) + m = load_manifest(p) + assert m.targets[0].account == "lewis" + assert m.targets[0].options == {"digest": "hi"} +``` + +- [ ] **Step 3: Run test (should fail)** + +Run: `pytest tests/core/test_manifest.py -v` +Expected: FAIL — `ModuleNotFoundError: core.manifest`. + +- [ ] **Step 4: Implement `core/manifest.py`** + +```python +"""Manifest schema, loading, normalization, and validation. + +A manifest is the user-facing YAML; once loaded it becomes a Manifest dataclass. +The lock-form (manifest.lock.json) is the normalized version emitted by +to_lock_dict for downstream tools. +""" + +from __future__ import annotations + +import json +from dataclasses import dataclass, field +from pathlib import Path +from typing import Any + +import yaml + +from core.errors import ManifestError + + +VALID_TYPES = {"image-post", "longform", "video-post"} +VALID_MODES = {"dry-run", "draft", "publish"} +SCHEMA_VERSION = "0.2" + + +@dataclass +class Target: + name: str + mode: str = "dry-run" + account: str = "default" + options: dict[str, Any] = field(default_factory=dict) + + def to_dict(self) -> dict[str, Any]: + return { + "name": self.name, + "mode": self.mode, + "account": self.account, + "options": dict(self.options), + } + + +@dataclass +class Manifest: + schema_version: str + type: str + title: str + body: str + mode: str + targets: list[Target] + summary: str | None = None + language: str = "zh-CN" + cover: str | None = None + images: list[str] = field(default_factory=list) + video: str | None = None + tags: list[str] = field(default_factory=list) + cta: str | None = None + metadata: dict[str, Any] = field(default_factory=dict) + source_path: Path | None = None # not serialized; for relative-path resolution + + def to_lock_dict(self) -> dict[str, Any]: + return { + "schema_version": self.schema_version, + "type": self.type, + "title": self.title, + "body": self.body, + "summary": self.summary, + "mode": self.mode, + "language": self.language, + "cover": self.cover, + "images": list(self.images), + "video": self.video, + "tags": list(self.tags), + "cta": self.cta, + "metadata": dict(self.metadata), + "targets": [t.to_dict() for t in self.targets], + } + + +def load_manifest(path: str | Path) -> Manifest: + p = Path(path).resolve() + if not p.exists(): + raise ManifestError(f"manifest not found: {p}") + raw = yaml.safe_load(p.read_text(encoding="utf-8")) + if not isinstance(raw, dict): + raise ManifestError(f"manifest must be a YAML mapping at top level: {p}") + return _from_dict(raw, base_dir=p.parent, source=p) + + +def _from_dict(raw: dict[str, Any], base_dir: Path, source: Path) -> Manifest: + for required in ("schema_version", "type", "title", "body", "mode", "targets"): + if required not in raw: + raise ManifestError(f"manifest missing required field: {required}") + + sv = str(raw["schema_version"]) + if sv != SCHEMA_VERSION: + raise ManifestError(f"unsupported schema_version {sv}; expected {SCHEMA_VERSION}") + + type_ = raw["type"] + if type_ not in VALID_TYPES: + raise ManifestError(f"invalid type {type_!r}; must be one of {sorted(VALID_TYPES)}") + + mode = raw["mode"] + if mode not in VALID_MODES: + raise ManifestError(f"invalid mode {mode!r}; must be one of {sorted(VALID_MODES)}") + + body_field = raw["body"] + body = _resolve_inline_or_path(body_field, base_dir) + + defaults = raw.get("defaults") or {} + if not isinstance(defaults, dict): + raise ManifestError("defaults must be a mapping") + default_account = defaults.get("account", "default") + default_options = defaults.get("options", {}) or {} + + raw_targets = raw["targets"] + if not isinstance(raw_targets, list) or not raw_targets: + raise ManifestError("targets must be a non-empty list") + targets = [_parse_target(t, mode, default_account, default_options) for t in raw_targets] + + assets = raw.get("assets") or {} + cover = assets.get("cover") + images = list(assets.get("images") or []) + video = assets.get("video") + + return Manifest( + schema_version=sv, + type=type_, + title=str(raw["title"]), + body=body, + mode=mode, + targets=targets, + summary=raw.get("summary"), + language=raw.get("language", "zh-CN"), + cover=cover, + images=images, + video=video, + tags=list(raw.get("tags") or []), + cta=raw.get("cta"), + metadata=dict(raw.get("metadata") or {}), + source_path=source, + ) + + +def _resolve_inline_or_path(value: Any, base_dir: Path) -> str: + if not isinstance(value, str): + raise ManifestError("body must be a string (inline) or path string") + s = value.strip() + if s.startswith("./") or s.startswith("../") or s.endswith(".md"): + candidate = (base_dir / s).resolve() + if candidate.exists(): + return candidate.read_text(encoding="utf-8") + return value + + +def _parse_target( + raw: Any, + top_mode: str, + default_account: str, + default_options: dict[str, Any], +) -> Target: + if isinstance(raw, str): + return Target( + name=raw, + mode=top_mode, + account=default_account, + options=dict(default_options), + ) + if isinstance(raw, dict): + if "target" not in raw: + raise ManifestError(f"target object missing 'target' key: {raw}") + merged_options = dict(default_options) + merged_options.update(raw.get("options") or {}) + m = raw.get("mode", top_mode) + if m not in VALID_MODES: + raise ManifestError(f"invalid target mode {m!r}") + return Target( + name=raw["target"], + mode=m, + account=raw.get("account", default_account), + options=merged_options, + ) + raise ManifestError(f"target must be string or mapping, got {type(raw).__name__}") + + +def write_lock(manifest: Manifest, run_dir: Path) -> Path: + lock_path = run_dir / "manifest.lock.json" + lock_path.write_text( + json.dumps(manifest.to_lock_dict(), indent=2, ensure_ascii=False), + encoding="utf-8", + ) + return lock_path +``` + +- [ ] **Step 5: Run test (should pass)** + +Run: `pytest tests/core/test_manifest.py -v` +Expected: PASS — 8 passed. + +- [ ] **Step 6: Commit** + +```bash +git add core/manifest.py tests/core/test_manifest.py tests/fixtures/ +git commit -m "feat(core): add Manifest schema, loading, normalization" +``` + +--- + +## Task 6: `core/credentials.py` — CredentialStore + age FileBackend + EnvBackend + +**Files:** +- Create: `core/credentials.py` +- Test: `tests/core/test_credentials.py` + +- [ ] **Step 1: Write failing test** + +`tests/core/test_credentials.py`: + +```python +import json +import os +from pathlib import Path + +import pytest + +from core.credentials import CredentialStore, EnvBackend, FileBackend +from core.errors import MissingCredentialError + + +@pytest.fixture +def isolated_vault(monkeypatch, tmp_path): + monkeypatch.delenv("XDG_CONFIG_HOME", raising=False) + monkeypatch.setenv("HOME", str(tmp_path)) + monkeypatch.delenv("MMP_VAULT_KEY", raising=False) + return tmp_path + + +def test_env_vault_key_overrides_file(monkeypatch, tmp_path): + """MMP_VAULT_KEY ENV is the canonical source when set; file is fallback.""" + monkeypatch.delenv("XDG_CONFIG_HOME", raising=False) + monkeypatch.setenv("HOME", str(tmp_path)) + # generate a key out-of-band + import pyrage + identity = pyrage.x25519.Identity.generate() + monkeypatch.setenv("MMP_VAULT_KEY", str(identity)) + + backend = FileBackend() + store = CredentialStore(backend=backend) + store.set("p", "default", {"K": "v"}) + + # Key file should NOT have been created when ENV is set + assert not (tmp_path / ".config" / "mmp" / "age-key.txt").exists() + + # And we can still read back + assert store.get("p", "default") == {"K": "v"} + + +def test_file_backend_roundtrip(isolated_vault): + backend = FileBackend() + store = CredentialStore(backend=backend) + store.set("wechat-article", "default", {"WECHAT_APP_ID": "wx", "WECHAT_APP_SECRET": "s"}) + + out = store.get("wechat-article", "default") + assert out == {"WECHAT_APP_ID": "wx", "WECHAT_APP_SECRET": "s"} + + +def test_file_backend_persists_encrypted(isolated_vault): + backend = FileBackend() + store = CredentialStore(backend=backend) + store.set("wechat-article", "default", {"WECHAT_APP_ID": "wx"}) + + vault_file = isolated_vault / ".config" / "mmp" / "credentials.json.age" + assert vault_file.exists() + raw = vault_file.read_bytes() + assert b"WECHAT_APP_ID" not in raw # encrypted, not visible + + +def test_get_missing_raises(isolated_vault): + store = CredentialStore(backend=FileBackend()) + with pytest.raises(MissingCredentialError): + store.get("wechat-article", "default") + + +def test_env_overrides_vault(isolated_vault, monkeypatch): + store = CredentialStore(backend=FileBackend()) + store.set("wechat-article", "default", {"WECHAT_APP_ID": "from_vault"}) + monkeypatch.setenv("WECHAT_APP_ID", "from_env") + + out = store.get("wechat-article", "default") + assert out["WECHAT_APP_ID"] == "from_env" + + +def test_list_accounts(isolated_vault): + store = CredentialStore(backend=FileBackend()) + store.set("wechat-article", "default", {"K": "v"}) + store.set("wechat-article", "lewis", {"K": "v2"}) + store.set("xiaohongshu", "default", {"K": "v3"}) + + assert sorted(store.list_accounts("wechat-article")) == ["default", "lewis"] + assert sorted(store.list_accounts(None)) == [ + "wechat-article:default", + "wechat-article:lewis", + "xiaohongshu:default", + ] + + +def test_delete(isolated_vault): + store = CredentialStore(backend=FileBackend()) + store.set("wechat-article", "default", {"K": "v"}) + store.delete("wechat-article", "default") + with pytest.raises(MissingCredentialError): + store.get("wechat-article", "default") + + +def test_env_only_backend(monkeypatch): + monkeypatch.setenv("WECHAT_APP_ID", "ww") + monkeypatch.setenv("WECHAT_APP_SECRET", "ss") + store = CredentialStore(backend=EnvBackend()) + out = store.get("wechat-article", "default") + assert out == {"WECHAT_APP_ID": "ww", "WECHAT_APP_SECRET": "ss"} + + +def test_required_keys_filtering(isolated_vault): + store = CredentialStore(backend=FileBackend()) + store.set("wechat-article", "default", {"WECHAT_APP_ID": "wx", "EXTRA": "x"}) + out = store.get("wechat-article", "default", required_keys=["WECHAT_APP_ID"]) + assert out == {"WECHAT_APP_ID": "wx"} +``` + +- [ ] **Step 2: Run test (should fail)** + +Run: `pytest tests/core/test_credentials.py -v` +Expected: FAIL — module not found. + +- [ ] **Step 3: Implement `core/credentials.py`** + +```python +"""Credential vault: encrypted file backend (age) + ENV backend. + +Vault layout (decrypted JSON): + { + "version": 1, + "accounts": { + ":": {"KEY": "value", ...}, + ... + } + } +""" + +from __future__ import annotations + +import json +import os +import secrets +from abc import ABC, abstractmethod +from pathlib import Path + +import pyrage + +from core import host +from core.errors import MissingCredentialError + + +_VAULT_VERSION = 1 + + +def _ensure_dir(p: Path) -> None: + p.mkdir(parents=True, exist_ok=True) + + +def _read_or_create_key(key_path: Path) -> tuple[pyrage.x25519.Identity, str]: + """Return (identity, recipient_string). + + Priority: + 1. ENV MMP_VAULT_KEY (canonical for CI / one-shot use) + 2. existing key file at key_path + 3. generate a new key file (first use) + """ + env_key = os.environ.get("MMP_VAULT_KEY", "").strip() + if env_key: + identity = pyrage.x25519.Identity.from_str(env_key) + return identity, str(identity.to_public()) + if not key_path.exists(): + identity = pyrage.x25519.Identity.generate() + key_path.parent.mkdir(parents=True, exist_ok=True) + key_path.write_text(str(identity), encoding="utf-8") + os.chmod(key_path, 0o600) + return identity, str(identity.to_public()) + text = key_path.read_text(encoding="utf-8").strip() + identity = pyrage.x25519.Identity.from_str(text) + return identity, str(identity.to_public()) + + +class Backend(ABC): + @abstractmethod + def read_all(self) -> dict[str, dict[str, str]]: ... + + @abstractmethod + def write_all(self, accounts: dict[str, dict[str, str]]) -> None: ... + + +class FileBackend(Backend): + """Age-encrypted JSON vault at host.vault_path().""" + + def __init__(self) -> None: + self._vault = host.vault_path() + self._key = host.vault_key_path() + + def read_all(self) -> dict[str, dict[str, str]]: + if not self._vault.exists(): + return {} + identity, _ = _read_or_create_key(self._key) + ciphertext = self._vault.read_bytes() + plaintext = pyrage.decrypt(ciphertext, [identity]) + data = json.loads(plaintext.decode("utf-8")) + if not isinstance(data, dict): + return {} + return data.get("accounts", {}) + + def write_all(self, accounts: dict[str, dict[str, str]]) -> None: + _ensure_dir(self._vault.parent) + identity, recipient_str = _read_or_create_key(self._key) + recipient = pyrage.x25519.Recipient.from_str(recipient_str) + body = json.dumps( + {"version": _VAULT_VERSION, "accounts": accounts}, ensure_ascii=False + ).encode("utf-8") + ciphertext = pyrage.encrypt(body, [recipient]) + self._vault.write_bytes(ciphertext) + os.chmod(self._vault, 0o600) + + +class EnvBackend(Backend): + """Read-only backend that pulls from os.environ. Used in CI.""" + + def read_all(self) -> dict[str, dict[str, str]]: + return {} + + def write_all(self, accounts: dict[str, dict[str, str]]) -> None: + raise NotImplementedError("EnvBackend is read-only") + + +class CredentialStore: + """Vault facade. ENV always overrides vault.""" + + def __init__(self, backend: Backend | None = None) -> None: + self._backend: Backend = backend or FileBackend() + + def set(self, provider: str, account: str, values: dict[str, str]) -> None: + all_ = self._backend.read_all() + all_[f"{provider}:{account}"] = dict(values) + self._backend.write_all(all_) + + def get( + self, + provider: str, + account: str = "default", + required_keys: list[str] | None = None, + ) -> dict[str, str]: + key = f"{provider}:{account}" + all_ = self._backend.read_all() + vault_values = dict(all_.get(key, {})) + + # ENV override: any matching key in os.environ wins + merged = dict(vault_values) + for k in list(merged.keys()): + if k in os.environ: + merged[k] = os.environ[k] + + # Also pick up env-only keys when required_keys is given + if required_keys: + for k in required_keys: + if k not in merged and k in os.environ: + merged[k] = os.environ[k] + missing = [k for k in required_keys if k not in merged] + if missing: + raise MissingCredentialError(provider=provider, keys=missing) + return {k: merged[k] for k in required_keys} + + if not merged: + raise MissingCredentialError(provider=provider, keys=["*"]) + return merged + + def list_accounts(self, provider: str | None = None) -> list[str]: + all_ = self._backend.read_all() + if provider is None: + return sorted(all_.keys()) + prefix = f"{provider}:" + return [k.removeprefix(prefix) for k in sorted(all_.keys()) if k.startswith(prefix)] + + def delete(self, provider: str, account: str) -> None: + all_ = self._backend.read_all() + all_.pop(f"{provider}:{account}", None) + self._backend.write_all(all_) +``` + +- [ ] **Step 4: Run test (should pass)** + +Run: `pytest tests/core/test_credentials.py -v` +Expected: PASS — 9 passed. + +- [ ] **Step 5: Commit** + +```bash +git add core/credentials.py tests/core/test_credentials.py +git commit -m "feat(core): add CredentialStore with age FileBackend + EnvBackend" +``` + +--- + +## Task 7: `core/provider.py` — Provider ABC + Registry + +**Files:** +- Create: `core/provider.py` +- Test: `tests/core/test_provider.py` + +- [ ] **Step 1: Write failing test** + +`tests/core/test_provider.py`: + +```python +from pathlib import Path + +import pytest +import yaml + +from core.errors import ProviderNotFoundError +from core.provider import ( + CredentialSpec, + PreparedPayload, + Provider, + ProviderRegistry, + ExecutionResult, + HealthStatus, + ValidationResult, +) +from core.rules import PlatformRules + + +def _write_provider_dir(root: Path, name: str, snake: str, body: str | None = None) -> Path: + """Write a fake provider package to disk and return its dir.""" + pdir = root / snake + pdir.mkdir(parents=True, exist_ok=True) + (pdir / "__init__.py").write_text("") + (pdir / "provider.yaml").write_text( + yaml.safe_dump( + { + "name": name, + "display_name": name, + "media_types": ["longform"], + "capabilities": {"draft": True, "publish": False, "schedule": False}, + "required_credentials": [ + {"key": "FAKE_KEY", "description": "x", "secret": True} + ], + "entry": "provider:FakeProvider", + "schema_version": 1, + } + ), + encoding="utf-8", + ) + pdir_body = body or ''' +from core.provider import Provider +from core.rules import PlatformRules + + +class FakeProvider(Provider): + name = "{name}" + display_name = "{name}" + media_types = ["longform"] + capabilities = {{"draft": True, "publish": False, "schedule": False}} + required_credentials = [] + platform_rules = PlatformRules() + + def validate(self, manifest, target): + return None + + def prepare(self, manifest, target, run_dir): + return None + + def execute(self, run_dir, target, mode, credentials): + return None +'''.format(name=name) + (pdir / "provider.py").write_text(pdir_body) + return pdir + + +def test_registry_discovers_bundled(tmp_path, monkeypatch): + bundled = tmp_path / "bundled" + bundled.mkdir() + _write_provider_dir(bundled, name="fake-one", snake="fake_one") + + reg = ProviderRegistry(bundled_dir=bundled, user_dir=tmp_path / "no_user") + reg.discover() + + info = reg.list() + assert any(i.name == "fake-one" for i in info) + + +def test_registry_resolve_by_kebab_name(tmp_path): + bundled = tmp_path / "bundled" + bundled.mkdir() + _write_provider_dir(bundled, name="fake-two", snake="fake_two") + + reg = ProviderRegistry(bundled_dir=bundled, user_dir=tmp_path / "no_user") + reg.discover() + + p = reg.resolve("fake-two") + assert p.name == "fake-two" + + +def test_resolve_missing_raises(tmp_path): + reg = ProviderRegistry(bundled_dir=tmp_path / "empty", user_dir=tmp_path / "empty2") + reg.discover() + with pytest.raises(ProviderNotFoundError): + reg.resolve("does-not-exist") + + +def test_user_provider_overrides_bundled(tmp_path): + bundled = tmp_path / "bundled" + user = tmp_path / "user" + bundled.mkdir() + user.mkdir() + + _write_provider_dir(bundled, name="dup", snake="dup_b") + _write_provider_dir( + user, + name="dup", + snake="dup_u", + body=( + "from core.provider import Provider\n" + "from core.rules import PlatformRules\n" + "class FakeProvider(Provider):\n" + " name = 'dup'\n" + " display_name = 'user-version'\n" + " media_types = ['longform']\n" + " capabilities = {'draft': True, 'publish': False, 'schedule': False}\n" + " required_credentials = []\n" + " platform_rules = PlatformRules()\n" + " def validate(self, m, t): return None\n" + " def prepare(self, m, t, r): return None\n" + " def execute(self, r, t, m, c): return None\n" + ), + ) + + reg = ProviderRegistry(bundled_dir=bundled, user_dir=user) + reg.discover(trust_user=True) + + p = reg.resolve("dup") + assert p.display_name == "user-version" + + +def test_filter_by_media_type(tmp_path): + bundled = tmp_path / "bundled" + bundled.mkdir() + _write_provider_dir(bundled, name="lf-only", snake="lf_only") + + reg = ProviderRegistry(bundled_dir=bundled, user_dir=tmp_path / "x") + reg.discover() + + longform = reg.list(media_type="longform") + assert any(i.name == "lf-only" for i in longform) + + images = reg.list(media_type="image-post") + assert all(i.name != "lf-only" for i in images) +``` + +- [ ] **Step 2: Run test (should fail)** + +Run: `pytest tests/core/test_provider.py -v` +Expected: FAIL — module not found. + +- [ ] **Step 3: Implement `core/provider.py`** + +```python +"""Provider abstract base class + registry. + +Discovery rules: + - bundled_dir: scan /*/provider.yaml + - user_dir: scan /*/provider.yaml + - User provider with same `name` overrides bundled, but only when + discover(trust_user=True) — otherwise warn and skip. + +Two names per provider: + - directory name: snake_case (Python module path) + - provider.yaml `name`: kebab-case (manifest target name) +""" + +from __future__ import annotations + +import importlib.util +import sys +from abc import ABC, abstractmethod +from dataclasses import dataclass, field +from enum import Enum +from pathlib import Path +from typing import Any + +import yaml + +from core.errors import ProviderNotFoundError +from core.rules import PlatformRules + + +class HealthStatus(str, Enum): + ok = "ok" + failed = "failed" + unknown = "unknown" + + +@dataclass +class CredentialSpec: + key: str + description: str = "" + secret: bool = True + setup_hint: str = "" + + +@dataclass +class ValidationResult: + violations: list = field(default_factory=list) + + +@dataclass +class PreparedPayload: + pack_dir: Path + payload_path: Path + extras: dict[str, Path] = field(default_factory=dict) + + +@dataclass +class ExecutionResult: + status: str # "ok" | "failed" | "skipped" | "partial" + mode_actual: str # "dry-run" | "draft-local" | "draft-platform" | "published" + external_id: str | None = None + draft_url: str | None = None + extras: dict[str, Any] = field(default_factory=dict) + + +@dataclass +class ProviderInfo: + name: str + display_name: str + media_types: list[str] + capabilities: dict[str, bool] + required_credentials: list[CredentialSpec] + source: str # "bundled" | "user" + + +class Provider(ABC): + name: str + display_name: str + media_types: list[str] + capabilities: dict[str, bool] + required_credentials: list[CredentialSpec] + platform_rules: PlatformRules + + @abstractmethod + def validate(self, manifest: Any, target: Any) -> ValidationResult | None: ... + + @abstractmethod + def prepare(self, manifest: Any, target: Any, run_dir: Path) -> PreparedPayload | None: ... + + @abstractmethod + def execute( + self, + run_dir: Path, + target: Any, + mode: str, + credentials: dict[str, str], + ) -> ExecutionResult | None: ... + + def health_check(self, credentials: dict[str, str]) -> HealthStatus: + return HealthStatus.unknown + + +class ProviderRegistry: + def __init__(self, bundled_dir: Path | None = None, user_dir: Path | None = None) -> None: + self._bundled_dir = bundled_dir or _default_bundled_dir() + self._user_dir = user_dir + self._providers: dict[str, Provider] = {} + self._info: dict[str, ProviderInfo] = {} + + def discover(self, trust_user: bool = False) -> None: + self._providers.clear() + self._info.clear() + # bundled first + if self._bundled_dir and self._bundled_dir.exists(): + for d in sorted(self._bundled_dir.iterdir()): + if d.is_dir() and (d / "provider.yaml").exists(): + self._load_provider(d, source="bundled") + # then user (overrides if trusted) + if trust_user and self._user_dir and self._user_dir.exists(): + for d in sorted(self._user_dir.iterdir()): + if d.is_dir() and (d / "provider.yaml").exists(): + self._load_provider(d, source="user") + + def resolve(self, name: str) -> Provider: + if name not in self._providers: + raise ProviderNotFoundError(f"provider not registered: {name}") + return self._providers[name] + + def list(self, media_type: str | None = None) -> list[ProviderInfo]: + infos = list(self._info.values()) + if media_type: + infos = [i for i in infos if media_type in i.media_types] + return infos + + def _load_provider(self, pdir: Path, source: str) -> None: + meta = yaml.safe_load((pdir / "provider.yaml").read_text(encoding="utf-8")) + name = meta["name"] + entry = meta["entry"] # e.g. "provider:FakeProvider" + module_file, class_name = entry.split(":", 1) + module_path = pdir / f"{module_file}.py" + if not module_path.exists(): + raise FileNotFoundError(f"provider entry module not found: {module_path}") + + mod_name = f"_mmp_provider_{pdir.name}" + spec = importlib.util.spec_from_file_location( + mod_name, module_path, submodule_search_locations=[str(pdir)] + ) + assert spec and spec.loader + module = importlib.util.module_from_spec(spec) + sys.modules[mod_name] = module + spec.loader.exec_module(module) + cls = getattr(module, class_name) + instance = cls() + + creds = [CredentialSpec(**c) for c in (meta.get("required_credentials") or [])] + info = ProviderInfo( + name=name, + display_name=meta.get("display_name", name), + media_types=list(meta.get("media_types") or []), + capabilities=dict(meta.get("capabilities") or {}), + required_credentials=creds, + source=source, + ) + self._providers[name] = instance + self._info[name] = info + + +def _default_bundled_dir() -> Path: + return Path(__file__).resolve().parent.parent / "providers" +``` + +- [ ] **Step 4: Run test (should pass)** + +Run: `pytest tests/core/test_provider.py -v` +Expected: PASS — 5 passed. + +- [ ] **Step 5: Commit** + +```bash +git add core/provider.py tests/core/test_provider.py +git commit -m "feat(core): add Provider ABC and ProviderRegistry" +``` + +--- + +## Task 8: `core/run.py` — Run Lifecycle, result.json, publish-log.md + +**Files:** +- Create: `core/run.py` +- Test: `tests/core/test_run.py` + +- [ ] **Step 1: Write failing test** + +`tests/core/test_run.py`: + +```python +import json +from pathlib import Path + +import pytest + +from core.run import Run, slugify + + +def test_slugify_basic(): + assert slugify("Hello World") == "hello-world" + assert slugify("AI 创业的三个误区").startswith("ai-") + assert slugify("a" * 100).__len__() <= 40 + + +def test_run_create_dir(tmp_path, monkeypatch): + monkeypatch.setenv("MMP_RUNS_DIR", str(tmp_path / "runs")) + r = Run.create(title="Hello", mmp_version="0.2.0", host="claude-code", mode="draft") + assert r.dir.exists() + assert (r.dir / "packs").exists() + assert (r.dir / "checkpoints").exists() + assert (r.dir / "artifacts").exists() + assert "hello" in r.dir.name + + +def test_result_serialization(tmp_path, monkeypatch): + monkeypatch.setenv("MMP_RUNS_DIR", str(tmp_path / "runs")) + r = Run.create(title="X", mmp_version="0.2.0", host="cc", mode="draft") + r.add_target_result( + name="wechat-article", + account="default", + status="ok", + mode_actual="draft-platform", + external_id="m_123", + draft_url=None, + ) + r.finalize() + data = json.loads((r.dir / "result.json").read_text()) + assert data["mode"] == "draft" + assert data["targets"][0]["name"] == "wechat-article" + assert data["targets"][0]["status"] == "ok" + assert data["targets"][0]["external_id"] == "m_123" + + +def test_log_append(tmp_path, monkeypatch): + monkeypatch.setenv("MMP_RUNS_DIR", str(tmp_path / "runs")) + r = Run.create(title="X", mmp_version="0.2.0", host="cc", mode="draft") + r.log("RUN_START", run_id=r.run_id) + r.log("PREPARE_OK", target="wechat-article") + text = (r.dir / "publish-log.md").read_text() + assert "RUN_START" in text + assert "PREPARE_OK" in text + + +def test_checkpoint_write_read(tmp_path, monkeypatch): + monkeypatch.setenv("MMP_RUNS_DIR", str(tmp_path / "runs")) + r = Run.create(title="X", mmp_version="0.2.0", host="cc", mode="draft") + r.checkpoint("wechat-article", step="thumb_uploaded", external_ids={"thumb_id": "t1"}) + cp = r.read_checkpoint("wechat-article") + assert cp["step"] == "thumb_uploaded" + assert cp["external_ids"]["thumb_id"] == "t1" + + +def test_resume_loads_existing_dir(tmp_path, monkeypatch): + monkeypatch.setenv("MMP_RUNS_DIR", str(tmp_path / "runs")) + r = Run.create(title="Y", mmp_version="0.2.0", host="cc", mode="draft") + r.checkpoint("x-article", step="prepared") + run_dir = r.dir + + r2 = Run.from_dir(run_dir) + assert r2.run_id == r.run_id + assert r2.read_checkpoint("x-article")["step"] == "prepared" +``` + +- [ ] **Step 2: Run test (should fail)** + +Run: `pytest tests/core/test_run.py -v` +Expected: FAIL — module not found. + +- [ ] **Step 3: Implement `core/run.py`** + +```python +"""Run lifecycle: directory creation, checkpoints, result.json, publish-log.md.""" + +from __future__ import annotations + +import json +import re +from dataclasses import dataclass, field +from datetime import datetime, timezone +from pathlib import Path +from typing import Any + +from core import host + + +_RESULT_SCHEMA_VERSION = 1 + + +def _now_iso() -> str: + return datetime.now(timezone.utc).isoformat(timespec="seconds").replace("+00:00", "Z") + + +def _now_id() -> str: + return datetime.now().strftime("%Y%m%d-%H%M%S") + + +def slugify(text: str, max_len: int = 40) -> str: + """ASCII-friendly lowercase slug. Non-ASCII collapse to hyphens; preserves + ASCII alphanumerics.""" + s = (text or "").lower().strip() + s = re.sub(r"[^\w\s-]+", "", s, flags=re.UNICODE) + s = re.sub(r"[\s_]+", "-", s) + s = re.sub(r"-+", "-", s).strip("-") + if not s: + s = "untitled" + return s[:max_len].rstrip("-") + + +@dataclass +class _TargetResult: + name: str + account: str + status: str + mode_actual: str + external_id: str | None = None + draft_url: str | None = None + started_at: str = field(default_factory=_now_iso) + completed_at: str | None = None + error: str | None = None + violations: list[dict[str, Any]] = field(default_factory=list) + + +@dataclass +class Run: + run_id: str + dir: Path + mode: str + mmp_version: str + host: str + started_at: str = field(default_factory=_now_iso) + completed_at: str | None = None + targets: list[_TargetResult] = field(default_factory=list) + + @classmethod + def create(cls, title: str, mmp_version: str, host: str, mode: str) -> "Run": + rid = f"{_now_id()}-{slugify(title)}" + d = host_runs_dir() / rid + d.mkdir(parents=True, exist_ok=True) + for sub in ("packs", "checkpoints", "artifacts"): + (d / sub).mkdir(exist_ok=True) + run = cls(run_id=rid, dir=d, mode=mode, mmp_version=mmp_version, host=host) + run.log("RUN_START", run_id=rid, mode=mode) + return run + + @classmethod + def from_dir(cls, run_dir: Path) -> "Run": + # Reconstruct minimal state from disk (used for resume). + rid = run_dir.name + mode = "draft" + # Try result.json if present + rp = run_dir / "result.json" + if rp.exists(): + data = json.loads(rp.read_text(encoding="utf-8")) + mode = data.get("mode", mode) + return cls(run_id=rid, dir=run_dir, mode=mode, mmp_version="?", host="?") + + def add_target_result( + self, + name: str, + account: str, + status: str, + mode_actual: str, + external_id: str | None = None, + draft_url: str | None = None, + error: str | None = None, + violations: list[dict[str, Any]] | None = None, + ) -> None: + self.targets.append( + _TargetResult( + name=name, + account=account, + status=status, + mode_actual=mode_actual, + external_id=external_id, + draft_url=draft_url, + completed_at=_now_iso(), + error=error, + violations=violations or [], + ) + ) + + def log(self, event: str, **kwargs: Any) -> None: + line_parts = [_now_iso(), event] + for k, v in kwargs.items(): + line_parts.append(f"{k}={v}") + line = " ".join(line_parts) + log_path = self.dir / "publish-log.md" + with log_path.open("a", encoding="utf-8") as f: + f.write(line + "\n") + + def checkpoint(self, target: str, step: str, **extras: Any) -> None: + cp_dir = self.dir / "checkpoints" + cp_dir.mkdir(exist_ok=True) + path = cp_dir / f"{target}.checkpoint.json" + payload = { + "target": target, + "step": step, + "started_at": _now_iso(), + "external_ids": extras.pop("external_ids", {}), + "next_step": extras.pop("next_step", None), + **extras, + } + path.write_text(json.dumps(payload, indent=2, ensure_ascii=False), encoding="utf-8") + + def read_checkpoint(self, target: str) -> dict[str, Any] | None: + path = self.dir / "checkpoints" / f"{target}.checkpoint.json" + if not path.exists(): + return None + return json.loads(path.read_text(encoding="utf-8")) + + def finalize(self) -> None: + self.completed_at = _now_iso() + result = { + "run_id": self.run_id, + "schema_version": _RESULT_SCHEMA_VERSION, + "manifest_path": "manifest.yaml", + "started_at": self.started_at, + "completed_at": self.completed_at, + "mode": self.mode, + "host": self.host, + "mmp_version": self.mmp_version, + "targets": [t.__dict__ for t in self.targets], + } + (self.dir / "result.json").write_text( + json.dumps(result, indent=2, ensure_ascii=False), encoding="utf-8" + ) + overall = "ok" if all(t.status == "ok" for t in self.targets) else "partial" + self.log("RUN_DONE", overall=overall) + + +def host_runs_dir() -> Path: + d = host.runs_dir() + d.mkdir(parents=True, exist_ok=True) + return d +``` + +- [ ] **Step 4: Run test (should pass)** + +Run: `pytest tests/core/test_run.py -v` +Expected: PASS — 6 passed. + +- [ ] **Step 5: Commit** + +```bash +git add core/run.py tests/core/test_run.py +git commit -m "feat(core): add Run lifecycle with result/log/checkpoint" +``` + +--- + +## Task 9: `providers/wechat_article` Scaffold + +**Files:** +- Create: `providers/wechat_article/__init__.py` +- Create: `providers/wechat_article/provider.yaml` +- Create: `providers/wechat_article/internal/__init__.py` +- Create: `providers/wechat_article/tests/__init__.py` + +- [ ] **Step 1: Create directory structure** + +```bash +mkdir -p providers/wechat_article/internal providers/wechat_article/tests +touch providers/wechat_article/__init__.py +touch providers/wechat_article/internal/__init__.py +touch providers/wechat_article/tests/__init__.py +``` + +- [ ] **Step 2: Write `providers/wechat_article/provider.yaml`** + +```yaml +name: wechat-article +display_name: 微信公众号文章 +media_types: + - longform +capabilities: + draft: true + publish: false + schedule: false +required_credentials: + - key: WECHAT_APP_ID + description: "WeChat Official Account AppID" + secret: false + setup_hint: "From mp.weixin.qq.com → 设置与开发 → 基本配置" + - key: WECHAT_APP_SECRET + description: "WeChat Official Account AppSecret" + secret: true + setup_hint: "Same page as AppID; reset if forgotten" +entry: provider:WeChatArticleProvider +schema_version: 1 +``` + +- [ ] **Step 3: Commit** + +```bash +git add providers/wechat_article/__init__.py providers/wechat_article/provider.yaml \ + providers/wechat_article/internal/__init__.py providers/wechat_article/tests/__init__.py +git commit -m "feat(providers): scaffold wechat_article provider" +``` + +--- + +## Task 10: Move WeChat API Helper to Provider Internal + +**Files:** +- Create: `providers/wechat_article/internal/wechat_api.py` (move from `scripts/wechat_api_draft.py`) +- Modify: `scripts/wechat_api_draft.py` (becomes thin wrapper, deprecated) + +- [ ] **Step 1: Read existing `scripts/wechat_api_draft.py`** + +Run: `wc -l scripts/wechat_api_draft.py` +Expected: ~280 lines (per HANDOFF section 8871 bytes). + +- [ ] **Step 2: Move file** + +```bash +git mv scripts/wechat_api_draft.py providers/wechat_article/internal/wechat_api.py +``` + +- [ ] **Step 3: Refactor module to expose pure functions** + +Edit `providers/wechat_article/internal/wechat_api.py`: +- Remove `if __name__ == "__main__":` block and argparse usage +- Keep public functions as a clean Python API: + - `get_access_token(app_id: str, app_secret: str) -> str` + - `upload_thumb(token: str, image_path: Path) -> str` (returns media_id) + - `add_draft(token: str, articles: list[dict]) -> str` (returns media_id) + - `draft_from_payload(payload: dict, credentials: dict, dry_run: bool) -> dict` +- Keep `requests` as-is (already a dep transitively); add to `pyproject.toml` if missing + +If `requests` not in deps, update `pyproject.toml`: +```toml +dependencies = [ + "pyyaml>=6.0", + "pyrage>=1.1", + "requests>=2.31", +] +``` + +- [ ] **Step 4: Add new `scripts/wechat_api_draft.py` as deprecation shim** + +```python +"""DEPRECATED: this CLI moved to `providers/wechat_article/internal/wechat_api.py`. + +It remains as a thin wrapper for backward compatibility with v0.1 scripts. +Will be removed in v0.3. +""" + +from __future__ import annotations + +import sys +import warnings + + +def main() -> int: + warnings.warn( + "scripts/wechat_api_draft.py is deprecated; use `mmp publish` or " + "`providers.wechat_article.internal.wechat_api`", + DeprecationWarning, + stacklevel=2, + ) + print( + "DEPRECATED. Use `python3 scripts/mmp.py publish ` instead.", + file=sys.stderr, + ) + return 1 + + +if __name__ == "__main__": + sys.exit(main()) +``` + +- [ ] **Step 5: Verify import works** + +Run: `python3 -c "from providers.wechat_article.internal import wechat_api; print(wechat_api.__name__)"` +Expected: prints `providers.wechat_article.internal.wechat_api` with no error. + +- [ ] **Step 6: Commit** + +```bash +git add scripts/wechat_api_draft.py providers/wechat_article/internal/wechat_api.py pyproject.toml +git commit -m "refactor(wechat_article): move API helper to provider internal" +``` + +--- + +## Task 11: `providers/wechat_article/rules.py` + +**Files:** +- Create: `providers/wechat_article/rules.py` +- Test: extend `providers/wechat_article/tests/test_provider.py` (next task) + +- [ ] **Step 1: Implement rules** + +```python +"""Platform rules for WeChat Official Account articles. + +Sources: +- 微信公众号文章正文长度上限 ~20000 中文字符 +- 标题最多 64 字符 +- 摘要最多 120 字符 +- 必须有封面图(thumb_media_id) +""" + +from __future__ import annotations + +from core.rules import PlatformRules, Severity, Violation + + +def _digest_lint(manifest, target_name: str) -> list[Violation]: + digest = (manifest.metadata or {}).get("digest") or (manifest.summary or "") + if digest and len(digest) > 120: + return [ + Violation( + code="WECHAT_DIGEST_TOO_LONG", + message=f"digest length {len(digest)} exceeds 120 chars", + target=target_name, + field_path="metadata.digest", + severity=Severity.warning, + ) + ] + return [] + + +def _cover_lint(manifest, target_name: str) -> list[Violation]: + if not getattr(manifest, "cover", None): + return [ + Violation( + code="WECHAT_COVER_REQUIRED", + message="WeChat article requires a cover image (assets.cover)", + target=target_name, + field_path="assets.cover", + severity=Severity.error, + ) + ] + return [] + + +WECHAT_ARTICLE_RULES = PlatformRules( + title_max=64, + body_max=20000, + cover_required=True, + extra_lints=[_digest_lint, _cover_lint], +) +``` + +- [ ] **Step 2: Commit** + +```bash +git add providers/wechat_article/rules.py +git commit -m "feat(wechat_article): add platform rules" +``` + +--- + +## Task 12: `providers/wechat_article/provider.py` — Validate + Prepare + +**Files:** +- Create: `providers/wechat_article/provider.py` +- Test: `providers/wechat_article/tests/test_provider.py` + +- [ ] **Step 1: Write failing test** + +`providers/wechat_article/tests/test_provider.py`: + +```python +import json +from pathlib import Path + +import pytest + +from core.manifest import Manifest, Target +from providers.wechat_article.provider import WeChatArticleProvider + + +@pytest.fixture +def sample_manifest(tmp_path): + return Manifest( + schema_version="0.2", + type="longform", + title="A reasonable title", + body="# Hello\n\nBody text.", + mode="dry-run", + targets=[Target(name="wechat-article")], + cover=str(tmp_path / "cover.png"), + summary="一句话摘要", + tags=["test"], + ) + + +def test_validate_passes(sample_manifest, tmp_path): + cover = Path(sample_manifest.cover) + cover.write_bytes(b"png-bytes") + p = WeChatArticleProvider() + res = p.validate(sample_manifest, sample_manifest.targets[0]) + assert all(v.severity.value != "error" for v in res.violations) + + +def test_validate_fails_when_no_cover(sample_manifest): + sample_manifest.cover = None + p = WeChatArticleProvider() + res = p.validate(sample_manifest, sample_manifest.targets[0]) + assert any(v.code == "WECHAT_COVER_REQUIRED" for v in res.violations) + + +def test_prepare_writes_payload(sample_manifest, tmp_path): + cover = Path(sample_manifest.cover) + cover.write_bytes(b"png-bytes") + run_dir = tmp_path / "run1" + run_dir.mkdir() + p = WeChatArticleProvider() + out = p.prepare(sample_manifest, sample_manifest.targets[0], run_dir) + assert out.payload_path.exists() + payload = json.loads(out.payload_path.read_text()) + assert payload["title"] == "A reasonable title" + assert payload["cover"].endswith("cover.png") + assert "html" in payload + assert "content" in payload +``` + +- [ ] **Step 2: Run test (should fail)** + +Run: `pytest providers/wechat_article/tests/test_provider.py -v` +Expected: FAIL — module/class not defined. + +- [ ] **Step 3: Implement provider validate + prepare** + +`providers/wechat_article/provider.py`: + +```python +"""WeChat Official Account article provider.""" + +from __future__ import annotations + +import json +from pathlib import Path +from typing import Any + +from core.errors import ProviderExecutionError +from core.provider import ( + CredentialSpec, + ExecutionResult, + HealthStatus, + PreparedPayload, + Provider, + ValidationResult, +) +from core.rules import Severity + +from providers.wechat_article.rules import WECHAT_ARTICLE_RULES + + +def _markdown_to_html(md: str) -> str: + """Minimal MD→HTML for WeChat draft API smoke. Not a full renderer.""" + out_lines: list[str] = [] + for line in md.splitlines(): + if line.startswith("# "): + out_lines.append(f"

{line[2:].strip()}

") + elif line.startswith("## "): + out_lines.append(f"

{line[3:].strip()}

") + elif line.startswith("### "): + out_lines.append(f"

{line[4:].strip()}

") + elif not line.strip(): + out_lines.append("") + else: + out_lines.append(f"

{line}

") + return "\n".join(out_lines) + + +class WeChatArticleProvider(Provider): + name = "wechat-article" + display_name = "微信公众号文章" + media_types = ["longform"] + capabilities = {"draft": True, "publish": False, "schedule": False} + required_credentials = [ + CredentialSpec( + key="WECHAT_APP_ID", + description="WeChat Official Account AppID", + secret=False, + setup_hint="From mp.weixin.qq.com → 设置与开发 → 基本配置", + ), + CredentialSpec( + key="WECHAT_APP_SECRET", + description="WeChat Official Account AppSecret", + secret=True, + setup_hint="Same page as AppID; reset if forgotten", + ), + ] + platform_rules = WECHAT_ARTICLE_RULES + + def validate(self, manifest: Any, target: Any) -> ValidationResult: + violations = self.platform_rules.lint(manifest, target_name=self.name) + return ValidationResult(violations=violations) + + def prepare(self, manifest: Any, target: Any, run_dir: Path) -> PreparedPayload: + pack_dir = run_dir / "packs" / self.name + pack_dir.mkdir(parents=True, exist_ok=True) + + digest = (manifest.metadata or {}).get("digest") or (manifest.summary or "") + body_md = manifest.body or "" + body_html = _markdown_to_html(body_md) + + payload = { + "title": manifest.title, + "content": body_md, + "html": body_html, + "digest": digest, + "tags": list(manifest.tags or []), + "cover": str(manifest.cover) if manifest.cover else None, + "mode": target.mode, + "options": dict(target.options or {}), + } + payload_path = pack_dir / "payload.json" + payload_path.write_text(json.dumps(payload, indent=2, ensure_ascii=False), encoding="utf-8") + (pack_dir / "content.md").write_text(body_md, encoding="utf-8") + + return PreparedPayload(pack_dir=pack_dir, payload_path=payload_path) + + def execute( + self, + run_dir: Path, + target: Any, + mode: str, + credentials: dict[str, str], + ) -> ExecutionResult: + # Implemented in next task + raise NotImplementedError("Task 13") + + def health_check(self, credentials: dict[str, str]) -> HealthStatus: + # Implemented in next task + return HealthStatus.unknown +``` + +- [ ] **Step 4: Run test (should pass)** + +Run: `pytest providers/wechat_article/tests/test_provider.py -v` +Expected: PASS — 3 passed. + +- [ ] **Step 5: Commit** + +```bash +git add providers/wechat_article/provider.py providers/wechat_article/tests/test_provider.py +git commit -m "feat(wechat_article): implement validate + prepare" +``` + +--- + +## Task 13: `wechat_article` Provider Execute (dry-run + draft) + health_check + +**Files:** +- Modify: `providers/wechat_article/provider.py` +- Test: extend `providers/wechat_article/tests/test_provider.py` + +- [ ] **Step 1: Write failing tests** + +Append to `providers/wechat_article/tests/test_provider.py`: + +```python +from unittest.mock import patch + + +def test_execute_dry_run_writes_pseudo_draft(sample_manifest, tmp_path): + cover = Path(sample_manifest.cover) + cover.write_bytes(b"png") + run_dir = tmp_path / "run2" + (run_dir / "packs" / "wechat-article").mkdir(parents=True) + p = WeChatArticleProvider() + p.prepare(sample_manifest, sample_manifest.targets[0], run_dir) + + res = p.execute(run_dir, sample_manifest.targets[0], mode="dry-run", credentials={}) + assert res.status == "ok" + assert res.mode_actual == "dry-run" + assert res.external_id is None + + +def test_execute_draft_calls_api(sample_manifest, tmp_path): + cover = Path(sample_manifest.cover) + cover.write_bytes(b"png") + run_dir = tmp_path / "run3" + (run_dir / "packs" / "wechat-article").mkdir(parents=True) + p = WeChatArticleProvider() + p.prepare(sample_manifest, sample_manifest.targets[0], run_dir) + + creds = {"WECHAT_APP_ID": "wx", "WECHAT_APP_SECRET": "s"} + with patch( + "providers.wechat_article.internal.wechat_api.get_access_token", + return_value="tok-123", + ), patch( + "providers.wechat_article.internal.wechat_api.upload_thumb", + return_value="thumb-id-1", + ), patch( + "providers.wechat_article.internal.wechat_api.add_draft", + return_value="draft-id-9", + ): + res = p.execute(run_dir, sample_manifest.targets[0], mode="draft", credentials=creds) + + assert res.status == "ok" + assert res.mode_actual == "draft-platform" + assert res.external_id == "draft-id-9" + + +def test_execute_publish_refused(sample_manifest, tmp_path): + cover = Path(sample_manifest.cover) + cover.write_bytes(b"png") + run_dir = tmp_path / "run4" + (run_dir / "packs" / "wechat-article").mkdir(parents=True) + p = WeChatArticleProvider() + p.prepare(sample_manifest, sample_manifest.targets[0], run_dir) + + with pytest.raises(NotImplementedError, match="publish"): + p.execute(run_dir, sample_manifest.targets[0], mode="publish", credentials={"a": "b"}) +``` + +- [ ] **Step 2: Run tests (should fail)** + +Run: `pytest providers/wechat_article/tests/test_provider.py -v` +Expected: FAIL — execute raises NotImplementedError("Task 13") for dry-run. + +- [ ] **Step 3: Replace `execute` and `health_check` in provider.py** + +In `providers/wechat_article/provider.py`, replace the `execute` and `health_check` methods: + +```python + def execute( + self, + run_dir: Path, + target: Any, + mode: str, + credentials: dict[str, str], + ) -> ExecutionResult: + if mode == "publish": + raise NotImplementedError( + "wechat-article publish path not enabled in v0.2; use mode=draft" + ) + + if mode == "dry-run": + return ExecutionResult(status="ok", mode_actual="dry-run", external_id=None) + + # mode == "draft" + from providers.wechat_article.internal import wechat_api # local import: optional dep + + payload_path = run_dir / "packs" / self.name / "payload.json" + payload = json.loads(payload_path.read_text(encoding="utf-8")) + + app_id = credentials.get("WECHAT_APP_ID") + app_secret = credentials.get("WECHAT_APP_SECRET") + if not app_id or not app_secret: + raise ProviderExecutionError( + target=self.name, + step="auth", + upstream=ValueError("missing WECHAT_APP_ID or WECHAT_APP_SECRET"), + retryable=False, + ) + + try: + token = wechat_api.get_access_token(app_id, app_secret) + except Exception as exc: + raise ProviderExecutionError( + target=self.name, step="get_token", upstream=exc, retryable=True + ) from exc + + try: + thumb_media_id = wechat_api.upload_thumb(token, Path(payload["cover"])) + except Exception as exc: + raise ProviderExecutionError( + target=self.name, step="upload_thumb", upstream=exc, retryable=True + ) from exc + + article = { + "title": payload["title"], + "thumb_media_id": thumb_media_id, + "content": payload["html"], + "digest": payload["digest"], + "show_cover_pic": 1, + "need_open_comment": 0, + "only_fans_can_comment": 0, + } + + try: + draft_id = wechat_api.add_draft(token, [article]) + except Exception as exc: + raise ProviderExecutionError( + target=self.name, step="add_draft", upstream=exc, retryable=True + ) from exc + + return ExecutionResult( + status="ok", + mode_actual="draft-platform", + external_id=draft_id, + extras={"thumb_media_id": thumb_media_id}, + ) + + def health_check(self, credentials: dict[str, str]) -> HealthStatus: + from providers.wechat_article.internal import wechat_api + + app_id = credentials.get("WECHAT_APP_ID") + app_secret = credentials.get("WECHAT_APP_SECRET") + if not (app_id and app_secret): + return HealthStatus.failed + try: + wechat_api.get_access_token(app_id, app_secret) + return HealthStatus.ok + except Exception: + return HealthStatus.failed +``` + +- [ ] **Step 4: Run tests (should pass)** + +Run: `pytest providers/wechat_article/tests/test_provider.py -v` +Expected: PASS — 6 passed (3 original + 3 new). + +- [ ] **Step 5: Commit** + +```bash +git add providers/wechat_article/provider.py providers/wechat_article/tests/test_provider.py +git commit -m "feat(wechat_article): implement execute + health_check" +``` + +--- + +## Task 14: `scripts/mmp.py` — CLI Skeleton + dispatch + +**Files:** +- Create: `scripts/mmp.py` +- Test: `tests/integration/test_cli_skeleton.py` + +- [ ] **Step 1: Write failing test** + +`tests/integration/test_cli_skeleton.py`: + +```python +import subprocess +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parent.parent.parent + + +def _run(*args, env=None): + return subprocess.run( + [sys.executable, str(ROOT / "scripts" / "mmp.py"), *args], + capture_output=True, + text=True, + env=env, + ) + + +def test_no_args_prints_help(): + p = _run() + assert p.returncode != 0 + assert "usage" in (p.stdout + p.stderr).lower() + + +def test_help_lists_subcommands(): + p = _run("--help") + out = p.stdout + p.stderr + for cmd in ["validate", "publish", "setup", "list", "resume", "doctor"]: + assert cmd in out +``` + +- [ ] **Step 2: Run test (should fail)** + +Run: `pytest tests/integration/test_cli_skeleton.py -v` +Expected: FAIL — file not found. + +- [ ] **Step 3: Implement `scripts/mmp.py`** + +```python +#!/usr/bin/env python3 +"""multi-media-publisher unified CLI entry. + +Subcommands: validate, publish, setup, list, resume, doctor, wizard. +The wizard subcommand is implemented in Plan 2. +""" + +from __future__ import annotations + +import argparse +import json +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parent.parent +sys.path.insert(0, str(ROOT)) + + +def _build_parser() -> argparse.ArgumentParser: + p = argparse.ArgumentParser(prog="mmp", description="multi-media-publisher CLI") + sub = p.add_subparsers(dest="cmd", required=True) + + sub_validate = sub.add_parser("validate", help="Validate a manifest without executing") + sub_validate.add_argument("manifest", help="Path to manifest.yaml") + + sub_publish = sub.add_parser("publish", help="Run prepare+execute for a manifest") + sub_publish.add_argument("manifest", help="Path to manifest.yaml") + sub_publish.add_argument( + "--mode-override", + choices=["dry-run", "draft", "publish"], + default=None, + help="Override manifest top-level mode (CAUTION with publish)", + ) + + sub_setup = sub.add_parser("setup", help="Configure credentials for a provider") + sub_setup.add_argument("provider", help="Provider name (e.g. wechat-article)") + sub_setup.add_argument("--account", default="default") + + sub_list = sub.add_parser("list", help="List providers / accounts / runs") + sub_list.add_argument("kind", choices=["providers", "accounts", "runs"]) + + sub_resume = sub.add_parser("resume", help="Resume a previously failed run") + sub_resume.add_argument("run_dir") + sub_resume.add_argument("--target", default=None) + + sub_doctor = sub.add_parser("doctor", help="Self-check: vault, providers, health") + + sub_wizard = sub.add_parser("wizard", help="Conversational manifest wizard (Plan 2)") + sub_wizard.add_argument("--type", choices=["image-post", "longform", "video-post"]) + sub_wizard.add_argument("--targets", default=None, help="Comma-separated target names") + + return p + + +def cmd_validate(args: argparse.Namespace) -> int: + from core.manifest import load_manifest + from core.provider import ProviderRegistry + from core.errors import MMPError + + try: + m = load_manifest(args.manifest) + reg = ProviderRegistry() + reg.discover() + all_violations = [] + for t in m.targets: + provider = reg.resolve(t.name) + res = provider.validate(m, t) + if res: + all_violations.extend(res.violations) + errs = [v for v in all_violations if v.severity.value == "error"] + warns = [v for v in all_violations if v.severity.value == "warning"] + for v in errs: + print(f"ERROR {v.target} {v.code} {v.message}", file=sys.stderr) + for v in warns: + print(f"WARN {v.target} {v.code} {v.message}", file=sys.stderr) + if errs: + return 2 + print(f"OK {len(m.targets)} targets validated.") + return 0 + except MMPError as e: + print(f"ERROR {e}", file=sys.stderr) + return 2 + + +def cmd_publish(args: argparse.Namespace) -> int: + from core.manifest import load_manifest, write_lock + from core.provider import ProviderRegistry + from core.credentials import CredentialStore + from core.run import Run + from core.errors import MMPError + + try: + m = load_manifest(args.manifest) + if args.mode_override: + m.mode = args.mode_override + for t in m.targets: + t.mode = args.mode_override + + reg = ProviderRegistry() + reg.discover() + store = CredentialStore() + + run = Run.create(title=m.title, mmp_version="0.2.0", host="cli", mode=m.mode) + # copy manifest.yaml + Path(run.dir / "manifest.yaml").write_text( + Path(args.manifest).read_text(encoding="utf-8"), encoding="utf-8" + ) + write_lock(m, run.dir) + + for t in m.targets: + run.log("TARGET_START", target=t.name, account=t.account) + try: + provider = reg.resolve(t.name) + v_res = provider.validate(m, t) + errs = [ + v for v in (v_res.violations if v_res else []) + if v.severity.value == "error" + ] + if errs: + run.add_target_result( + name=t.name, + account=t.account, + status="failed", + mode_actual="dry-run", + error=f"validation: {[v.code for v in errs]}", + violations=[v.__dict__ for v in errs], + ) + run.log("VALIDATE_FAIL", target=t.name, codes=[v.code for v in errs]) + continue + + provider.prepare(m, t, run.dir) + run.log("PREPARE_OK", target=t.name) + + creds = {} + if t.mode != "dry-run": + required = [c.key for c in provider.required_credentials] + creds = store.get(t.name, t.account, required_keys=required) + + exec_res = provider.execute(run.dir, t, t.mode, creds) + run.add_target_result( + name=t.name, + account=t.account, + status=exec_res.status, + mode_actual=exec_res.mode_actual, + external_id=exec_res.external_id, + draft_url=exec_res.draft_url, + ) + run.log( + "EXECUTE_OK", + target=t.name, + mode_actual=exec_res.mode_actual, + external_id=exec_res.external_id, + ) + except Exception as exc: + run.add_target_result( + name=t.name, + account=t.account, + status="failed", + mode_actual=t.mode, + error=str(exc), + ) + run.log("TARGET_FAIL", target=t.name, error=str(exc)) + + run.finalize() + print(f"RUN_DIR {run.dir}") + return 0 + except MMPError as e: + print(f"ERROR {e}", file=sys.stderr) + return 2 + + +def cmd_setup(args: argparse.Namespace) -> int: + from core.credentials import CredentialStore + from core.provider import ProviderRegistry + + reg = ProviderRegistry() + reg.discover() + try: + provider = reg.resolve(args.provider) + except Exception as e: + print(f"ERROR {e}", file=sys.stderr) + return 2 + + store = CredentialStore() + values: dict[str, str] = {} + print(f"Configure {args.provider} (account: {args.account}). Press Enter to skip a key.") + for spec in provider.required_credentials: + prompt = f" {spec.key}" + if spec.description: + prompt += f" ({spec.description})" + prompt += ": " + if spec.secret: + import getpass + v = getpass.getpass(prompt) + else: + v = input(prompt) + if v: + values[spec.key] = v + if values: + store.set(args.provider, args.account, values) + print(f"OK saved {len(values)} keys to vault.") + else: + print("nothing to save.") + return 0 + + +def cmd_list(args: argparse.Namespace) -> int: + if args.kind == "providers": + from core.provider import ProviderRegistry + reg = ProviderRegistry() + reg.discover() + for info in reg.list(): + caps = ",".join(k for k, v in info.capabilities.items() if v) + print(f" {info.name} ({info.source}) media={info.media_types} caps={caps}") + elif args.kind == "accounts": + from core.credentials import CredentialStore + store = CredentialStore() + for acc in store.list_accounts(): + print(f" {acc}") + elif args.kind == "runs": + from core import host as h + rd = h.runs_dir() + if rd.exists(): + for d in sorted(rd.iterdir()): + if d.is_dir(): + print(f" {d.name}") + return 0 + + +def cmd_resume(args: argparse.Namespace) -> int: + print("resume not implemented in Plan 1; coming soon.", file=sys.stderr) + return 1 + + +def cmd_doctor(args: argparse.Namespace) -> int: + from core import host as h + from core.provider import ProviderRegistry + from core.credentials import CredentialStore + + print(f"host: {h.detect_host()}") + print(f"vault: {h.vault_path()} exists={h.vault_path().exists()}") + reg = ProviderRegistry() + reg.discover() + print(f"providers: {len(reg.list())}") + store = CredentialStore() + print(f"accounts: {len(store.list_accounts())}") + return 0 + + +def cmd_wizard(args: argparse.Namespace) -> int: + print("wizard subcommand will be implemented in Plan 2.", file=sys.stderr) + return 1 + + +_DISPATCH = { + "validate": cmd_validate, + "publish": cmd_publish, + "setup": cmd_setup, + "list": cmd_list, + "resume": cmd_resume, + "doctor": cmd_doctor, + "wizard": cmd_wizard, +} + + +def main(argv: list[str] | None = None) -> int: + p = _build_parser() + args = p.parse_args(argv) + return _DISPATCH[args.cmd](args) + + +if __name__ == "__main__": + sys.exit(main()) +``` + +- [ ] **Step 4: Make executable & run test** + +```bash +chmod +x scripts/mmp.py +pytest tests/integration/test_cli_skeleton.py -v +``` + +Expected: PASS — 2 passed. + +- [ ] **Step 5: Commit** + +```bash +git add scripts/mmp.py tests/integration/test_cli_skeleton.py +git commit -m "feat(cli): add mmp.py with validate/publish/setup/list/resume/doctor" +``` + +--- + +## Task 15: Integration Test — wechat_article End-to-End (dry-run) + +**Files:** +- Create: `tests/integration/test_wechat_article_e2e.py` +- Create: `tests/fixtures/wechat-article-e2e.yaml` +- Create: `tests/fixtures/wechat-article-e2e.body.md` +- Create: `tests/fixtures/wechat-article-cover.png` (any tiny PNG) + +- [ ] **Step 1: Create fixtures** + +`tests/fixtures/wechat-article-e2e.body.md`: + +```markdown +# AI Agent 不是工具 + +而是一种新的组织形态。 + +## 一 + +正文段落。 +``` + +`tests/fixtures/wechat-article-e2e.yaml`: + +```yaml +schema_version: "0.2" +type: longform +title: "AI Agent 不是工具" +body: ./wechat-article-e2e.body.md +mode: dry-run +language: zh-CN +targets: + - wechat-article +assets: + cover: ./wechat-article-cover.png +summary: "一句话摘要 测试" +tags: + - AI +metadata: + digest: "用作公众号文章摘要的字段" +``` + +Create cover PNG: + +```bash +python3 -c "import struct; open('tests/fixtures/wechat-article-cover.png','wb').write(bytes.fromhex('89504e470d0a1a0a0000000d49484452000000010000000108020000009077533de0000000016352474200aece1ce90000000c4944415478da6300010000050001a5f645400000000049454e44ae426082'))" +``` + +- [ ] **Step 2: Write integration test** + +`tests/integration/test_wechat_article_e2e.py`: + +```python +import json +import subprocess +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parent.parent.parent +FIXTURE = ROOT / "tests" / "fixtures" / "wechat-article-e2e.yaml" + + +def test_publish_dry_run_creates_run_dir(tmp_path, monkeypatch): + monkeypatch.setenv("MMP_RUNS_DIR", str(tmp_path / "runs")) + + p = subprocess.run( + [sys.executable, str(ROOT / "scripts" / "mmp.py"), "publish", str(FIXTURE)], + capture_output=True, + text=True, + env={**__import__("os").environ, "MMP_RUNS_DIR": str(tmp_path / "runs")}, + ) + assert p.returncode == 0, p.stderr + + runs = list((tmp_path / "runs").iterdir()) + assert len(runs) == 1 + rd = runs[0] + assert (rd / "manifest.lock.json").exists() + assert (rd / "result.json").exists() + assert (rd / "publish-log.md").exists() + assert (rd / "packs" / "wechat-article" / "payload.json").exists() + + result = json.loads((rd / "result.json").read_text()) + assert result["mode"] == "dry-run" + assert len(result["targets"]) == 1 + t = result["targets"][0] + assert t["name"] == "wechat-article" + assert t["status"] == "ok" + assert t["mode_actual"] == "dry-run" + + +def test_validate_subcommand(tmp_path): + p = subprocess.run( + [sys.executable, str(ROOT / "scripts" / "mmp.py"), "validate", str(FIXTURE)], + capture_output=True, + text=True, + ) + assert p.returncode == 0, p.stderr + assert "OK" in p.stdout +``` + +- [ ] **Step 3: Run integration test** + +Run: `pytest tests/integration/test_wechat_article_e2e.py -v` +Expected: PASS — 2 passed. + +- [ ] **Step 4: Commit** + +```bash +git add tests/integration/test_wechat_article_e2e.py tests/fixtures/ +git commit -m "test(integration): wechat_article dry-run end-to-end" +``` + +--- + +## Task 16: Update Makefile + Smoke Test + +**Files:** +- Modify: `Makefile` +- Modify: `scripts/test_local.py` + +- [ ] **Step 1: Read existing Makefile** + +Run: `cat Makefile` +Expected: existing `test:` target invoking `scripts/test_local.py`. + +- [ ] **Step 2: Replace Makefile** + +```makefile +.PHONY: test lint typecheck unit smoke clean + +test: lint typecheck unit smoke + +unit: + python3 -m pytest -q + +lint: + python3 -m ruff check . + +typecheck: + python3 -m mypy core + +smoke: + python3 scripts/test_local.py + +clean: + rm -rf .pytest_cache .mypy_cache .ruff_cache __pycache__ + find . -name "__pycache__" -type d -exec rm -rf {} + + find . -name "*.pyc" -delete +``` + +- [ ] **Step 3: Update `scripts/test_local.py`** + +Replace `scripts/test_local.py` with: + +```python +#!/usr/bin/env python3 +"""Local smoke test for multi-media-publisher v0.2. + +Runs a few CLI flows in a tmp dir and asserts shape. Does NOT call any +external network. +""" + +from __future__ import annotations + +import json +import os +import subprocess +import sys +import tempfile +from pathlib import Path + +ROOT = Path(__file__).resolve().parent.parent +FIXTURE = ROOT / "tests" / "fixtures" / "wechat-article-e2e.yaml" + + +def _run_mmp(*args, env_extra: dict | None = None) -> subprocess.CompletedProcess: + env = dict(os.environ) + if env_extra: + env.update(env_extra) + return subprocess.run( + [sys.executable, str(ROOT / "scripts" / "mmp.py"), *args], + capture_output=True, + text=True, + env=env, + ) + + +def main() -> int: + with tempfile.TemporaryDirectory(prefix="mmp-smoke-") as tmp: + tmp_path = Path(tmp) + runs_dir = tmp_path / "runs" + env_extra = { + "MMP_RUNS_DIR": str(runs_dir), + "XDG_CONFIG_HOME": str(tmp_path / "xdg"), + } + + # 1. validate + p = _run_mmp("validate", str(FIXTURE), env_extra=env_extra) + assert p.returncode == 0, f"validate failed:\n{p.stderr}" + + # 2. publish dry-run + p = _run_mmp("publish", str(FIXTURE), env_extra=env_extra) + assert p.returncode == 0, f"publish dry-run failed:\n{p.stderr}" + runs = list(runs_dir.iterdir()) + assert len(runs) == 1 + rd = runs[0] + result = json.loads((rd / "result.json").read_text()) + assert result["targets"][0]["status"] == "ok" + assert result["targets"][0]["mode_actual"] == "dry-run" + + # 3. doctor + p = _run_mmp("doctor", env_extra=env_extra) + assert p.returncode == 0, f"doctor failed:\n{p.stderr}" + + # 4. list + p = _run_mmp("list", "providers", env_extra=env_extra) + assert p.returncode == 0 + assert "wechat-article" in p.stdout + + print(json.dumps({"ok": True, "tmp": str(tmp_path), "run": str(rd)}, indent=2)) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) +``` + +- [ ] **Step 4: Run smoke** + +Run: `make smoke` +Expected: prints `{"ok": true, ...}` and exit 0. + +- [ ] **Step 5: Run full test target** + +Run: `make test` +Expected: lint + typecheck + unit + smoke all pass. + +- [ ] **Step 6: Commit** + +```bash +git add Makefile scripts/test_local.py +git commit -m "build: Makefile with lint/typecheck/unit/smoke; rewrite smoke for v0.2 CLI" +``` + +--- + +## Task 17: Update SKILL.md to v0.2 + +**Files:** +- Modify: `SKILL.md` + +- [ ] **Step 1: Replace SKILL.md** + +```markdown +--- +name: Multi-media Publisher +description: This skill should be used when the user asks to "多媒体发布", "多平台发布", "同步发布小红书和微信图文", "发微信图文和小红书", "发布长文章到公众号/X/Substack", "cross-post", "publish everywhere", or wants one content package adapted and published/drafted across Xiaohongshu, WeChat image posts, WeChat Official Account articles, X Articles/Twitter, Substack, or future video platforms. +version: 0.2.0 +--- + +# Multi-media Publisher / 多媒体发布 + +Cross-platform content publishing orchestration. Routes one source content +package to multiple platform providers via a unified manifest, draft-first +safety policy, and per-platform rules. + +## When to use + +- User wants to publish/draft the same content across multiple platforms +- User wants to add a new platform/provider +- User needs to validate a manifest, set up credentials, or inspect runs + +## Entry point + +All operations go through `scripts/mmp.py`: + +```bash +python3 scripts/mmp.py [args] +``` + +Subcommands: + +- `validate ` — validate without executing +- `publish [--mode-override ...]` — prepare + execute +- `setup [--account NAME]` — configure credentials +- `list providers|accounts|runs` — inspect state +- `resume [--target NAME]` — recover failed run +- `doctor` — self-check +- `wizard [--type ... --targets ...]` — conversational manifest builder (Plan 2) + +## Default mode = draft + +Every run defaults to `mode: draft`. Public publishing requires explicit +top-level `mode: publish` AND a second confirmation in conversation. + +## v0.2 supported providers + +| Provider | Media | Modes | +|---|---|---| +| `wechat-article` | longform | dry-run, draft (Plan 1) | +| `xiaohongshu` | image-post | (Plan 3) | +| `wechat-image` | image-post | (Plan 3) | +| `x-article` | longform | (Plan 3) | +| `substack` | longform | (Plan 3) | + +## Safety rules + +1. Never publish publicly without explicit confirmation +2. Never bypass login, CAPTCHA, platform review, or anti-abuse safeguards +3. Treat all credentials as secrets; never print them +4. If browser automation reaches an ambiguous screen, stop and ask +5. Read `docs/safety-policy.md` (was `references/publishing-policy.md`) + +## Architecture + +See `docs/superpowers/specs/2026-05-05-multi-media-publisher-redesign-design.md` +for the full architecture spec. In short: + +- **Shell**: this SKILL.md + `.claude-plugin/plugin.json` (Plan 4) +- **Core**: `core/` — host-agnostic Python (manifest, provider registry, vault, run lifecycle) +- **Providers**: `providers//` (bundled) + `~/.config/mmp/providers//` (user) + +## Quickstart + +```bash +# 1. Validate +python3 scripts/mmp.py validate examples/longform.yaml + +# 2. Configure WeChat credentials +python3 scripts/mmp.py setup wechat-article + +# 3. Dry-run +python3 scripts/mmp.py publish examples/longform.yaml --mode-override dry-run + +# 4. Inspect runs +python3 scripts/mmp.py list runs +``` + +## Bundled resources + +- `core/` — manifest, provider, credentials, run, rules, host, errors +- `providers/wechat_article/` — first-party WeChat OA article provider +- `examples/longform.yaml` — sample manifest +- `docs/HANDOFF.md` — historical state notes +- `docs/superpowers/specs/2026-05-05-multi-media-publisher-redesign-design.md` — v0.2 design spec +- `docs/superpowers/plans/2026-05-05-plan-*.md` — v0.2 implementation plans +``` + +- [ ] **Step 2: Commit** + +```bash +git add SKILL.md +git commit -m "docs: rewrite SKILL.md for v0.2 architecture" +``` + +--- + +## Task 18: Update HANDOFF.md & README.md + +**Files:** +- Modify: `docs/HANDOFF.md` +- Modify: `README.md` + +- [ ] **Step 1: Append status block to HANDOFF.md** + +Append to `docs/HANDOFF.md`: + +```markdown + +--- + +## v0.2 Redesign — In Progress + +Active spec: `docs/superpowers/specs/2026-05-05-multi-media-publisher-redesign-design.md` +Plans: `docs/superpowers/plans/2026-05-05-plan-{1,2,3,4}-*.md` + +### Plan 1 status (this commit range) + +- Core architecture: `core/` modules in place (manifest, provider, credentials, run, rules, host, errors) +- First provider migrated: `wechat_article` (validate + prepare + execute draft + health_check) +- CLI: `scripts/mmp.py` with validate/publish/setup/list/resume/doctor +- Tests: unit + integration; `make test` covers lint + typecheck + unit + smoke +- Old scripts: `wechat_api_draft.py` deprecated (shim only) + +### Open items after Plan 1 + +- Wizard subcommand: stub only; implemented in Plan 2 +- Remaining providers (xiaohongshu / wechat_image / x_article / substack): Plan 3 +- Plugin marketplace prep + CI: Plan 4 +- Real WeChat account verification: see `docs/manual-verification.md` (Plan 4) +``` + +- [ ] **Step 2: Update README.md** + +Replace `README.md`: + +```markdown +# Multi-media Publisher / 多媒体发布 + +统一调度多平台内容发布的 OpenClaw skill / Claude Code plugin。 + +## 现状(v0.2 进行中) + +- 架构:`core/` + `providers//` + `scripts/mmp.py` +- 已迁移:`wechat-article` 全链路(validate/prepare/execute draft) +- 进行中:`wizard`(Plan 2)、其他 4 个 provider(Plan 3)、CC plugin + CI(Plan 4) + +详见: + +- 设计:[docs/superpowers/specs/2026-05-05-multi-media-publisher-redesign-design.md](docs/superpowers/specs/2026-05-05-multi-media-publisher-redesign-design.md) +- 计划:[docs/superpowers/plans/](docs/superpowers/plans/) +- 交接:[docs/HANDOFF.md](docs/HANDOFF.md) +- Skill:[SKILL.md](SKILL.md) + +## 安装 + +```bash +python3 -m pip install -e ".[dev]" +``` + +## 用法 + +```bash +# 校验 +python3 scripts/mmp.py validate examples/longform.yaml + +# 配置凭证 +python3 scripts/mmp.py setup wechat-article + +# 发布(默认 draft 模式) +python3 scripts/mmp.py publish examples/longform.yaml + +# 自检 +python3 scripts/mmp.py doctor + +# 测试 +make test +``` + +## 安全策略 + +- 默认 `draft` 模式 +- 公开发布要求显式 `publish` 模式 + 对话二次确认 +- 凭证存于 age 加密的 `~/.config/mmp/credentials.json.age` +- 详见 `docs/safety-policy.md` +``` + +- [ ] **Step 3: Commit** + +```bash +git add docs/HANDOFF.md README.md +git commit -m "docs: update HANDOFF and README for v0.2 plan-1 progress" +``` + +--- + +## Task 19: Final Lint & Typecheck Pass + +- [ ] **Step 1: Run ruff** + +Run: `python3 -m ruff check .` +Expected: 0 issues. Fix any reported. + +- [ ] **Step 2: Run ruff format check** + +Run: `python3 -m ruff format --check .` +Expected: 0 changes needed. If reformatting needed, run `ruff format .` and commit. + +- [ ] **Step 3: Run mypy** + +Run: `python3 -m mypy core` +Expected: 0 errors. Fix any reported by adding type hints. + +- [ ] **Step 4: Run full test** + +Run: `make test` +Expected: all green. + +- [ ] **Step 5: Commit any cleanup** + +```bash +git add -A +git status +# only commit if files changed +git diff --cached --quiet || git commit -m "chore: lint/typecheck cleanup" +``` + +--- + +## Self-Review Checklist + +Before declaring Plan 1 done: + +- [ ] All tasks 1–19 committed +- [ ] `make test` green +- [ ] `python3 scripts/mmp.py doctor` shows ≥1 provider +- [ ] `python3 scripts/mmp.py publish tests/fixtures/wechat-article-e2e.yaml` produces a run dir with `result.json` status=ok +- [ ] Spec sections covered: §3 (architecture), §4 (provider contract), §5 (manifest), §6 (vault), §8 (run lifecycle), §10.1 (wechat_article migration) +- [ ] Spec sections deferred to later plans: §7 (wizard → Plan 2), §10.1 remaining providers (Plan 3), §9 (dual-host plugin) + §11 (CI) → Plan 4 + +## Hand-off to Plan 2 + +Plan 2 (Wizard Flow) reads: + +- `core.wizard` package skeleton needed +- `scripts/mmp.py wizard` currently stub returning exit 1 +- SKILL.md `wizard` reference in subcommands list + +Plan 2 starts after Plan 1 self-review passes. diff --git a/docs/superpowers/plans/2026-05-05-plan-2-wizard-flow.md b/docs/superpowers/plans/2026-05-05-plan-2-wizard-flow.md new file mode 100644 index 0000000..9ef9f29 --- /dev/null +++ b/docs/superpowers/plans/2026-05-05-plan-2-wizard-flow.md @@ -0,0 +1,1366 @@ +# Plan 2 — Wizard Flow + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Replace hand-written manifest YAML with a 3-stage Claude-driven conversational wizard (source extraction → target selection → manifest assembly), plus a setup-credentials sub-flow. + +**Architecture:** The wizard is split between **markdown prompt fragments** (read by Claude during conversation) and a **thin Python helper layer** (dumps current registry/credential context as JSON for Claude; commits validated manifests to disk). Claude is the actual driver; Python supplies state and validation. + +**Tech Stack:** Python 3.10+ (stdlib `tomllib` + `tomli_w` for settings), pytest. No new external deps. + +**Spec reference:** `docs/superpowers/specs/2026-05-05-multi-media-publisher-redesign-design.md` §7 (Wizard Flow), §6.4 (Setup wizard). + +**Depends on:** Plan 1 complete (`core/` + `providers/wechat_article/` + `scripts/mmp.py` skeleton with `wizard` stub). + +--- + +## File Structure + +**Created in this plan:** + +``` +core/wizard/__init__.py +core/wizard/source_extraction.md +core/wizard/target_selection.md +core/wizard/manifest_assembly.md +core/wizard/credential_setup.md +core/wizard/loader.py # render prompt fragments with context vars +core/wizard/context.py # build runtime context (providers/accounts/settings) +core/wizard/commit.py # validate and write a draft manifest +core/settings.py # read/write ~/.config/mmp/settings.toml +tests/core/test_settings.py +tests/core/test_wizard_context.py +tests/core/test_wizard_loader.py +tests/core/test_wizard_commit.py +tests/integration/test_wizard_cli.py +``` + +**Modified:** + +``` +scripts/mmp.py # wire up wizard subcommand; route setup through credential_setup +SKILL.md # add wizard triggers + "how Claude runs the wizard" section +pyproject.toml # add tomli_w to deps +docs/HANDOFF.md # Plan 2 status note +``` + +--- + +## Task 1: `core/settings.py` — settings.toml read/write + +**Files:** +- Create: `core/settings.py` +- Test: `tests/core/test_settings.py` +- Modify: `pyproject.toml` + +- [ ] **Step 1: Add `tomli_w` to dependencies** + +Edit `pyproject.toml`, in `[project] dependencies`: + +```toml +dependencies = [ + "pyyaml>=6.0", + "pyrage>=1.1", + "requests>=2.31", + "tomli_w>=1.0", +] +``` + +- [ ] **Step 2: Write failing test** + +`tests/core/test_settings.py`: + +```python +import pytest + +from core import settings + + +@pytest.fixture +def isolated(monkeypatch, tmp_path): + monkeypatch.delenv("XDG_CONFIG_HOME", raising=False) + monkeypatch.setenv("HOME", str(tmp_path)) + return tmp_path + + +def test_default_when_missing(isolated): + s = settings.load() + assert s.default_mode == "draft" + assert s.wizard_enabled is True + assert s.auto_save_manifest is True + assert s.trusted_user_providers == [] + + +def test_set_and_reload(isolated): + s = settings.load() + s.default_mode = "dry-run" + s.trusted_user_providers = ["my-thing"] + settings.save(s) + + s2 = settings.load() + assert s2.default_mode == "dry-run" + assert s2.trusted_user_providers == ["my-thing"] + + +def test_settings_file_path(isolated): + s = settings.load() + settings.save(s) + assert (isolated / ".config" / "mmp" / "settings.toml").exists() +``` + +- [ ] **Step 3: Run test (should fail)** + +Run: `pytest tests/core/test_settings.py -v` +Expected: FAIL — module not found. + +- [ ] **Step 4: Implement `core/settings.py`** + +```python +"""Settings persistence at ~/.config/mmp/settings.toml. + +Defaults are returned when the file is missing; save() writes back the full +settings object. +""" + +from __future__ import annotations + +import sys +from dataclasses import dataclass, field, asdict +from pathlib import Path + +if sys.version_info >= (3, 11): + import tomllib +else: # pragma: no cover + import tomli as tomllib + +import tomli_w + +from core import host + + +@dataclass +class Settings: + default_mode: str = "draft" + wizard_enabled: bool = True + auto_save_manifest: bool = True + trusted_user_providers: list[str] = field(default_factory=list) + + +def load() -> Settings: + p = host.settings_path() + if not p.exists(): + return Settings() + with p.open("rb") as f: + data = tomllib.load(f) + wiz = data.get("wizard", {}) + providers = data.get("providers", {}) + return Settings( + default_mode=data.get("default_mode", "draft"), + wizard_enabled=wiz.get("enabled", True), + auto_save_manifest=wiz.get("auto_save_manifest", True), + trusted_user_providers=list(providers.get("trusted_user_providers", [])), + ) + + +def save(s: Settings) -> Path: + p = host.settings_path() + p.parent.mkdir(parents=True, exist_ok=True) + payload = { + "default_mode": s.default_mode, + "wizard": { + "enabled": s.wizard_enabled, + "auto_save_manifest": s.auto_save_manifest, + }, + "providers": { + "trusted_user_providers": list(s.trusted_user_providers), + }, + } + with p.open("wb") as f: + tomli_w.dump(payload, f) + return p +``` + +- [ ] **Step 5: Run test (should pass)** + +Run: `pytest tests/core/test_settings.py -v` +Expected: PASS — 3 passed. + +- [ ] **Step 6: Commit** + +```bash +git add core/settings.py tests/core/test_settings.py pyproject.toml +git commit -m "feat(core): add settings.toml read/write" +``` + +--- + +## Task 2: `core/wizard/` Skeleton + `loader.py` + +**Files:** +- Create: `core/wizard/__init__.py` +- Create: `core/wizard/loader.py` +- Test: `tests/core/test_wizard_loader.py` + +- [ ] **Step 1: Create directory + init** + +```bash +mkdir -p core/wizard +touch core/wizard/__init__.py +``` + +- [ ] **Step 2: Write failing test** + +`tests/core/test_wizard_loader.py`: + +```python +from pathlib import Path + +import pytest + +from core.wizard.loader import list_stages, render + + +def test_list_stages(): + stages = list_stages() + assert "source_extraction" in stages + assert "target_selection" in stages + assert "manifest_assembly" in stages + assert "credential_setup" in stages + + +def test_render_basic_substitution(tmp_path): + # write a tiny test prompt to a temp dir and render it + test_prompt = tmp_path / "demo.md" + test_prompt.write_text("Hello, {{name}}!\n", encoding="utf-8") + out = render(test_prompt, name="World") + assert out.strip() == "Hello, World!" + + +def test_render_missing_var_raises(tmp_path): + test_prompt = tmp_path / "x.md" + test_prompt.write_text("Hello {{missing}}", encoding="utf-8") + with pytest.raises(KeyError): + render(test_prompt) + + +def test_render_real_stage_returns_text(): + text = render("source_extraction") + assert len(text) > 0 + assert isinstance(text, str) +``` + +- [ ] **Step 3: Run test (should fail)** + +Run: `pytest tests/core/test_wizard_loader.py -v` +Expected: FAIL — module not found. + +- [ ] **Step 4: Implement `core/wizard/loader.py`** + +```python +"""Load and render markdown prompt fragments for the wizard. + +Templating: tiny {{var}} substitution, KeyError on missing var. Not Jinja — +prompts should stay simple and human-editable. +""" + +from __future__ import annotations + +import re +from pathlib import Path + +_STAGES = ["source_extraction", "target_selection", "manifest_assembly", "credential_setup"] +_PLACEHOLDER = re.compile(r"\{\{\s*(\w+)\s*\}\}") +_FRAGMENT_DIR = Path(__file__).resolve().parent + + +def list_stages() -> list[str]: + return list(_STAGES) + + +def render(stage_or_path: str | Path, **vars: object) -> str: + if isinstance(stage_or_path, Path): + path = stage_or_path + else: + path = _FRAGMENT_DIR / f"{stage_or_path}.md" + text = path.read_text(encoding="utf-8") + + def _sub(match: re.Match[str]) -> str: + key = match.group(1) + if key not in vars: + raise KeyError(f"missing wizard variable: {key}") + return str(vars[key]) + + return _PLACEHOLDER.sub(_sub, text) +``` + +- [ ] **Step 5: Add placeholder fragments (to satisfy `list_stages` real-load test)** + +Create empty placeholder files (real content arrives in Tasks 4–7): + +```bash +for f in source_extraction target_selection manifest_assembly credential_setup; do + echo "" > "core/wizard/$f.md" +done +``` + +- [ ] **Step 6: Run test (should pass)** + +Run: `pytest tests/core/test_wizard_loader.py -v` +Expected: PASS — 4 passed. + +- [ ] **Step 7: Commit** + +```bash +git add core/wizard/__init__.py core/wizard/loader.py core/wizard/*.md tests/core/test_wizard_loader.py +git commit -m "feat(wizard): add loader + stage placeholders" +``` + +--- + +## Task 3: `core/wizard/context.py` — Build Runtime Context for Claude + +**Files:** +- Create: `core/wizard/context.py` +- Test: `tests/core/test_wizard_context.py` + +- [ ] **Step 1: Write failing test** + +`tests/core/test_wizard_context.py`: + +```python +import json +from pathlib import Path + +import pytest +import yaml + +from core.wizard.context import build_context + + +def _write_provider(root: Path, name: str, snake: str, media_types: list[str]) -> None: + pdir = root / snake + pdir.mkdir(parents=True, exist_ok=True) + (pdir / "__init__.py").write_text("") + (pdir / "provider.yaml").write_text( + yaml.safe_dump( + { + "name": name, + "display_name": name, + "media_types": media_types, + "capabilities": {"draft": True, "publish": False, "schedule": False}, + "required_credentials": [], + "entry": "provider:P", + "schema_version": 1, + } + ), + encoding="utf-8", + ) + (pdir / "provider.py").write_text( + "from core.provider import Provider\n" + "from core.rules import PlatformRules\n" + "class P(Provider):\n" + f" name = '{name}'\n" + f" display_name = '{name}'\n" + f" media_types = {media_types}\n" + " capabilities = {'draft': True, 'publish': False, 'schedule': False}\n" + " required_credentials = []\n" + " platform_rules = PlatformRules()\n" + " def validate(self, m, t): return None\n" + " def prepare(self, m, t, r): return None\n" + " def execute(self, r, t, m, c): return None\n" + ) + + +def test_context_lists_providers(tmp_path, monkeypatch): + bundled = tmp_path / "bundled" + bundled.mkdir() + _write_provider(bundled, name="lf-only", snake="lf_only", media_types=["longform"]) + _write_provider(bundled, name="img-only", snake="img_only", media_types=["image-post"]) + + monkeypatch.delenv("XDG_CONFIG_HOME", raising=False) + monkeypatch.setenv("HOME", str(tmp_path)) + + ctx = build_context(bundled_dir=bundled) + assert any(p["name"] == "lf-only" for p in ctx["providers"]) + assert any(p["name"] == "img-only" for p in ctx["providers"]) + + +def test_context_filters_by_type(tmp_path, monkeypatch): + bundled = tmp_path / "bundled" + bundled.mkdir() + _write_provider(bundled, name="lf-only", snake="lf_only", media_types=["longform"]) + _write_provider(bundled, name="img-only", snake="img_only", media_types=["image-post"]) + + monkeypatch.delenv("XDG_CONFIG_HOME", raising=False) + monkeypatch.setenv("HOME", str(tmp_path)) + + ctx = build_context(bundled_dir=bundled, media_type="longform") + names = [p["name"] for p in ctx["providers"]] + assert "lf-only" in names + assert "img-only" not in names + + +def test_context_includes_accounts_and_settings(tmp_path, monkeypatch): + monkeypatch.delenv("XDG_CONFIG_HOME", raising=False) + monkeypatch.setenv("HOME", str(tmp_path)) + + ctx = build_context(bundled_dir=tmp_path / "empty") + assert "accounts" in ctx + assert "settings" in ctx + assert ctx["settings"]["default_mode"] == "draft" + + +def test_context_marks_credential_status(tmp_path, monkeypatch): + bundled = tmp_path / "bundled" + bundled.mkdir() + pdir = bundled / "needy" + pdir.mkdir() + (pdir / "__init__.py").write_text("") + (pdir / "provider.yaml").write_text( + yaml.safe_dump( + { + "name": "needy", + "display_name": "needy", + "media_types": ["longform"], + "capabilities": {"draft": True, "publish": False, "schedule": False}, + "required_credentials": [{"key": "FOO", "description": "foo", "secret": True}], + "entry": "provider:P", + "schema_version": 1, + } + ), + encoding="utf-8", + ) + (pdir / "provider.py").write_text( + "from core.provider import Provider\n" + "from core.rules import PlatformRules\n" + "class P(Provider):\n" + " name='needy'\n display_name='needy'\n media_types=['longform']\n" + " capabilities={'draft': True, 'publish': False, 'schedule': False}\n" + " required_credentials=[]\n platform_rules=PlatformRules()\n" + " def validate(self,m,t): return None\n" + " def prepare(self,m,t,r): return None\n" + " def execute(self,r,t,m,c): return None\n" + ) + monkeypatch.delenv("XDG_CONFIG_HOME", raising=False) + monkeypatch.setenv("HOME", str(tmp_path)) + + ctx = build_context(bundled_dir=bundled) + p = next(p for p in ctx["providers"] if p["name"] == "needy") + assert p["credential_status"] == "missing" +``` + +- [ ] **Step 2: Run test (should fail)** + +Run: `pytest tests/core/test_wizard_context.py -v` +Expected: FAIL — module not found. + +- [ ] **Step 3: Implement `core/wizard/context.py`** + +```python +"""Build a JSON context object describing current wizard state. + +Claude reads this (via `mmp wizard --dump-context`) to know which providers +are available, which accounts have credentials, and what the user's settings +say. The context is the bridge between Python state and Claude conversation. +""" + +from __future__ import annotations + +from pathlib import Path +from typing import Any + +from core import settings as settings_mod +from core.credentials import CredentialStore +from core.errors import MissingCredentialError +from core.provider import ProviderRegistry + + +def build_context( + bundled_dir: Path | None = None, + user_dir: Path | None = None, + media_type: str | None = None, +) -> dict[str, Any]: + reg = ProviderRegistry(bundled_dir=bundled_dir, user_dir=user_dir) + s = settings_mod.load() + reg.discover(trust_user=False) + + store = CredentialStore() + accounts = store.list_accounts() + + providers_out: list[dict[str, Any]] = [] + for info in reg.list(media_type=media_type): + # credential status + if not info.required_credentials: + cred_status = "n/a" + else: + keys = [c.key for c in info.required_credentials] + try: + store.get(info.name, "default", required_keys=keys) + cred_status = "ok" + except MissingCredentialError: + cred_status = "missing" + + providers_out.append( + { + "name": info.name, + "display_name": info.display_name, + "media_types": info.media_types, + "capabilities": info.capabilities, + "required_credentials": [ + {"key": c.key, "description": c.description, "secret": c.secret} + for c in info.required_credentials + ], + "credential_status": cred_status, + "source": info.source, + } + ) + + return { + "providers": providers_out, + "accounts": accounts, + "settings": { + "default_mode": s.default_mode, + "wizard_enabled": s.wizard_enabled, + "auto_save_manifest": s.auto_save_manifest, + }, + } +``` + +- [ ] **Step 4: Run test (should pass)** + +Run: `pytest tests/core/test_wizard_context.py -v` +Expected: PASS — 4 passed. + +- [ ] **Step 5: Commit** + +```bash +git add core/wizard/context.py tests/core/test_wizard_context.py +git commit -m "feat(wizard): build_context for Claude consumption" +``` + +--- + +## Task 4: `core/wizard/source_extraction.md` — Stage 1 Prompt + +**Files:** +- Modify: `core/wizard/source_extraction.md` (replacing placeholder) + +- [ ] **Step 1: Write Stage 1 prompt** + +```markdown +# Wizard Stage 1 — Source Extraction + +You are guiding the user to convert source content into a structured draft. +**Do not write the manifest yet** — that happens in Stage 3 (`manifest_assembly.md`). + +## Goal of this stage + +Produce an internal draft holding these fields (in your conversation memory, not on disk yet): + +- `type`: one of `image-post` / `longform` / `video-post` +- `title`: short title +- `body`: full content (Markdown for longform; caption text for image-post) +- `summary`: optional one-line synopsis +- `cover`: optional path to a cover image +- `images`: list of image paths (image-post only) +- `video`: path to video file (video-post only) +- `tags`: optional tag list +- `cta`: optional call-to-action text + +## How to behave + +1. Read whatever the user has shared so far — pasted text, file paths, links, screenshots. +2. **Determine `type`** by what's there: + - Multiple images + short caption → `image-post` + - Long markdown article → `longform` + - Video file → `video-post` + - Ambiguous → ask one short question +3. **Extract candidate fields** silently. Do not fabricate; if a field is unknown, leave it None. +4. **Reflect back** the extraction in 4–6 lines: + + ``` + type: longform + title: + body: ... + cover: + tags: + ``` + +5. **Ask ONE blocking question at a time**, only for missing required fields. Required: + - `type` (always) + - `title` (always) + - `body` (always) + - `cover` for `longform` (most providers require it) + - `images` for `image-post` +6. Do NOT ask about target platforms here — that is Stage 2. +7. Do NOT ask about `mode` — that is Stage 2/3. + +## When to advance + +Once `type`, `title`, `body`, and (cover OR images depending on type) are present, +say: + +> Source captured. Moving to target selection. + +Then proceed to load `core/wizard/target_selection.md` and follow it. + +## Examples of good behavior + +- User pastes a markdown file path → extract `body` from the file, infer `title` + from the first H1 heading, ask only for cover. +- User pastes a folder of images → list as `images`, ask for caption, infer + `type=image-post`. +- User pastes raw text → ask whether it's the full article or a draft to expand. +``` + +- [ ] **Step 2: Commit** + +```bash +git add core/wizard/source_extraction.md +git commit -m "feat(wizard): add stage 1 source_extraction prompt" +``` + +--- + +## Task 5: `core/wizard/target_selection.md` — Stage 2 Prompt + +**Files:** +- Modify: `core/wizard/target_selection.md` + +- [ ] **Step 1: Write Stage 2 prompt** + +```markdown +# Wizard Stage 2 — Target Selection + +You have a draft from Stage 1. Now choose where to publish. + +## Goal of this stage + +Produce a list of `Target` entries: + +```json +[ + {"target": "wechat-article", "mode": "draft", "account": "default", "options": {}}, + {"target": "x-article", "mode": "draft", "account": "lewis", "options": {}} +] +``` + +## How to behave + +1. **Get available providers**: run + + ```bash + python3 scripts/mmp.py wizard --dump-context --type + ``` + + The output is JSON with `providers`, `accounts`, `settings`. + +2. **Filter to compatible providers**: media_types must include the draft's `type`. + +3. **Show the list** to the user with status markers: + + ``` + Available targets for : + ✓ wechat-article (creds: ok) — 微信公众号文章 + ✗ xiaohongshu (creds: missing) — 小红书图文 [run `mmp setup xiaohongshu` first] + ✓ x-article (creds: ok) — X Articles + ✓ substack (creds: ok) — Substack + ``` + +4. **Ask which targets to use** as a multi-pick (e.g. "1, 3" or names). + +5. **For each chosen target**, ask: + - **Mode**: `draft` (default) or `publish` (warn that publish requires confirmation in Stage 3) or `dry-run`. + - **Account**: if multiple accounts exist for that provider, list them. Otherwise default to `default`. + - **Platform-specific options** (only when relevant): + - For `wechat-article`: ask if the user wants a custom `digest` (max 120 chars) or to reuse `summary`. + - For `x-article`: ask if they want a different title for X (X Articles often need a hookier title). + - For `xiaohongshu`: ask about hashtags / hook. Title max 20 chars. + - For `substack`: ask about subtitle / paid-tier flag. + +6. Do NOT proceed if a chosen target has `credential_status: missing`. Tell the + user which `mmp setup ` to run, and either wait for them to do it + (then re-run `--dump-context`) or drop that target. + +## When to advance + +Once you have at least one target with mode and account, say: + +> Targets locked in. Moving to manifest assembly. + +Proceed to `core/wizard/manifest_assembly.md`. + +## What NOT to do + +- Don't pick targets the user didn't ask for. +- Don't silently downgrade `publish` to `draft` — flag it explicitly. +- Don't ask about platforms not in the registry. +``` + +- [ ] **Step 2: Commit** + +```bash +git add core/wizard/target_selection.md +git commit -m "feat(wizard): add stage 2 target_selection prompt" +``` + +--- + +## Task 6: `core/wizard/manifest_assembly.md` — Stage 3 Prompt + +**Files:** +- Modify: `core/wizard/manifest_assembly.md` + +- [ ] **Step 1: Write Stage 3 prompt** + +```markdown +# Wizard Stage 3 — Manifest Assembly + +You now have a Stage 1 draft and a Stage 2 target list. Time to render the +manifest, validate it, get user approval, and persist. + +## Goal of this stage + +1. Render `manifest.yaml` from collected info. +2. Validate via `python3 scripts/mmp.py validate `. +3. Show the user the rendered YAML AND the violation report. +4. On approval, commit via `python3 scripts/mmp.py wizard --commit `. + +## Render rules + +- Always include `schema_version: "0.2"` at the top. +- Use full-form targets when modes/accounts/options differ; short-form (string) + when all defaults apply. +- For body: if the user pasted file content, write the file out to a sibling + path and reference it with `./.md`. If body is short and inline, embed. +- Cover and images: keep absolute paths if user gave absolute; otherwise + relative to the manifest file. + +## Validation flow + +1. Write the rendered YAML to a temp path: + + ```bash + tmp=$(mktemp -d)/manifest.yaml + # write yaml content to $tmp + ``` + +2. Run validate: + + ```bash + python3 scripts/mmp.py validate "$tmp" + ``` + +3. Parse the output: + - Exit 0 + "OK" → green light. + - Exit 2 with "ERROR" lines → blocking violations; surface each as + "violation: " and tell the user how to fix. + - Lines starting with "WARN" → soft warnings; show them but allow continue. + +## Approval gate + +Show the user: + +``` +Manifest: + + +Validation: +[errors and warnings listed] + +Confirm? (y/n/edit) +``` + +- `y` → commit. +- `n` → return to Stage 2 to revise targets, or Stage 1 to revise content. +- `edit` → ask which field to change, modify, re-render, re-validate. + +## Publish-mode safety gate + +If ANY target has `mode: publish`, after the user says `y`, ask one more time: + +> The following targets will publish PUBLICLY (not just draft): +> - wechat-article (account: default) +> - x-article (account: lewis) +> +> Confirm public publish? (yes/no) + +Only proceed on exact match `yes`. Anything else → downgrade to `draft` for +safety and inform the user. + +## Commit + +```bash +python3 scripts/mmp.py wizard --commit /tmp/manifest.yaml +``` + +The CLI prints `RUN_DIR `. The manifest is now at `/manifest.yaml` +and a `manifest.lock.json` is generated. + +## Hand-off + +Tell the user the run dir and ask whether to also execute now: + +> Manifest ready at `/manifest.yaml`. Execute now? +> yes → run `mmp publish /manifest.yaml` +> later → I'll stop here; run `mmp publish` when you're ready. +``` + +- [ ] **Step 2: Commit** + +```bash +git add core/wizard/manifest_assembly.md +git commit -m "feat(wizard): add stage 3 manifest_assembly prompt" +``` + +--- + +## Task 7: `core/wizard/credential_setup.md` — Setup Flow Prompt + +**Files:** +- Modify: `core/wizard/credential_setup.md` + +- [ ] **Step 1: Write credential setup prompt** + +```markdown +# Wizard — Credential Setup + +Triggered when: +- User says "setup credentials" / "添加账号" / "configure " +- A different stage discovers `credential_status: missing` for a target + +## Goal + +Populate `~/.config/mmp/credentials.json.age` with the keys the chosen +provider's `required_credentials` list. ENV variables override vault for +the current session. + +## How to behave + +1. **Identify provider + account**. + - Provider: ask if not given (e.g. "Which provider? wechat-article / xiaohongshu / x-article / substack") + - Account: default is `default`. Multi-account users may want `lewis`, `work`, etc. + +2. **Show what's needed**: run + + ```bash + python3 scripts/mmp.py wizard --dump-context | jq '.providers[] | select(.name=="")' + ``` + + List each `required_credentials` entry with its `description` and `setup_hint`. + +3. **For each key**, prompt the user: + - If `secret: false`: ask normally; the value is shown in the conversation (e.g. AppID). + - If `secret: true`: instruct user to paste in chat; warn that this conversation may be logged on their side. **Never echo the secret back in your reply.** Confirm receipt with "(received)". + +4. **Persist** by running: + + ```bash + python3 scripts/mmp.py setup --account + ``` + + This subcommand prompts via stdin/getpass for each key. **Tell the user this + is the safer path** — they enter the secret directly into the CLI, never + into chat. + + Alternative (one-shot, less secure): pass through ENV-prefixed values: + + ```bash + WECHAT_APP_ID=wx... WECHAT_APP_SECRET=... \ + python3 scripts/mmp.py publish manifest.yaml + ``` + +5. **Verify**: run + + ```bash + python3 scripts/mmp.py doctor + ``` + + Confirm the account appears in `accounts:` count. + +6. **Optional: health check** (only if user wants the network round-trip): + + For wechat-article: verify by calling `get_access_token`. The provider's + `health_check` returns `ok` / `failed`. We do not expose this in the CLI in + v0.2; tell the user it'll come in v0.3. + +## Security reminders to surface + +- Tell the user: "I will not store, re-display, or log this secret." +- If the user pastes a secret in chat, advise them: "Consider rotating this key + after we're done — chat history is on your side." +- Never pass secrets as command-line arguments (visible in `ps`). +``` + +- [ ] **Step 2: Commit** + +```bash +git add core/wizard/credential_setup.md +git commit -m "feat(wizard): add credential_setup prompt" +``` + +--- + +## Task 8: `core/wizard/commit.py` — Commit a Validated Manifest + +**Files:** +- Create: `core/wizard/commit.py` +- Test: `tests/core/test_wizard_commit.py` + +- [ ] **Step 1: Write failing test** + +`tests/core/test_wizard_commit.py`: + +```python +from pathlib import Path + +import pytest + +from core.errors import ManifestError +from core.wizard.commit import commit_manifest + + +VALID_YAML = """\ +schema_version: "0.2" +type: longform +title: "Test" +body: "inline body" +mode: dry-run +targets: + - wechat-article +""" + + +def test_commit_valid(tmp_path, monkeypatch): + monkeypatch.setenv("MMP_RUNS_DIR", str(tmp_path / "runs")) + src = tmp_path / "src.yaml" + src.write_text(VALID_YAML, encoding="utf-8") + + run_dir = commit_manifest(src) + assert run_dir.exists() + assert (run_dir / "manifest.yaml").read_text() == VALID_YAML + assert (run_dir / "manifest.lock.json").exists() + + +def test_commit_invalid_raises(tmp_path, monkeypatch): + monkeypatch.setenv("MMP_RUNS_DIR", str(tmp_path / "runs")) + src = tmp_path / "src.yaml" + src.write_text("not valid yaml: ::", encoding="utf-8") + with pytest.raises(ManifestError): + commit_manifest(src) +``` + +- [ ] **Step 2: Run test (should fail)** + +Run: `pytest tests/core/test_wizard_commit.py -v` +Expected: FAIL — module not found. + +- [ ] **Step 3: Implement `core/wizard/commit.py`** + +```python +"""Commit a validated manifest from the wizard into a fresh run dir.""" + +from __future__ import annotations + +from pathlib import Path + +from core.manifest import load_manifest, write_lock +from core.run import Run + + +def commit_manifest(src_path: str | Path) -> Path: + src = Path(src_path).resolve() + manifest = load_manifest(src) + + run = Run.create( + title=manifest.title, + mmp_version="0.2.0", + host="wizard", + mode=manifest.mode, + ) + target_path = run.dir / "manifest.yaml" + target_path.write_text(src.read_text(encoding="utf-8"), encoding="utf-8") + write_lock(manifest, run.dir) + run.log("WIZARD_COMMIT", source=str(src)) + return run.dir +``` + +- [ ] **Step 4: Run test (should pass)** + +Run: `pytest tests/core/test_wizard_commit.py -v` +Expected: PASS — 2 passed. + +- [ ] **Step 5: Commit** + +```bash +git add core/wizard/commit.py tests/core/test_wizard_commit.py +git commit -m "feat(wizard): commit_manifest validates and writes to run dir" +``` + +--- + +## Task 9: Wire `mmp wizard` Subcommand + +**Files:** +- Modify: `scripts/mmp.py` +- Test: `tests/integration/test_wizard_cli.py` + +- [ ] **Step 1: Replace `cmd_wizard` and update parser in `scripts/mmp.py`** + +Replace the existing wizard parser block with: + +```python + sub_wizard = sub.add_parser("wizard", help="Conversational manifest wizard") + sub_wizard.add_argument("--type", choices=["image-post", "longform", "video-post"]) + sub_wizard.add_argument("--targets", default=None, help="Comma-separated target names") + sub_wizard.add_argument( + "--dump-context", + action="store_true", + help="Dump current context as JSON for Claude to read", + ) + sub_wizard.add_argument( + "--commit", + metavar="MANIFEST_PATH", + default=None, + help="Validate a manifest YAML and persist as a new run dir", + ) +``` + +Replace `cmd_wizard` body: + +```python +def cmd_wizard(args: argparse.Namespace) -> int: + if args.dump_context: + from core.wizard.context import build_context + ctx = build_context(media_type=args.type) + print(json.dumps(ctx, indent=2, ensure_ascii=False)) + return 0 + if args.commit: + from core.wizard.commit import commit_manifest + from core.errors import MMPError + try: + run_dir = commit_manifest(args.commit) + print(f"RUN_DIR {run_dir}") + return 0 + except MMPError as e: + print(f"ERROR {e}", file=sys.stderr) + return 2 + # interactive (no flags) — Claude is expected to drive via SKILL.md prompts + print( + "wizard interactive mode is driven by Claude reading core/wizard/*.md.\n" + "Run with --dump-context to fetch state, or --commit to persist a manifest.", + file=sys.stderr, + ) + return 1 +``` + +- [ ] **Step 2: Write integration test** + +`tests/integration/test_wizard_cli.py`: + +```python +import json +import os +import subprocess +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parent.parent.parent + + +def _run(*args, env_extra=None): + env = dict(os.environ) + if env_extra: + env.update(env_extra) + return subprocess.run( + [sys.executable, str(ROOT / "scripts" / "mmp.py"), *args], + capture_output=True, + text=True, + env=env, + ) + + +def test_dump_context_returns_json(tmp_path): + p = _run( + "wizard", + "--dump-context", + env_extra={ + "MMP_RUNS_DIR": str(tmp_path / "runs"), + "XDG_CONFIG_HOME": str(tmp_path / "xdg"), + }, + ) + assert p.returncode == 0 + data = json.loads(p.stdout) + assert "providers" in data + assert "accounts" in data + assert "settings" in data + + +def test_dump_context_filters_by_type(tmp_path): + p = _run( + "wizard", + "--dump-context", + "--type", + "longform", + env_extra={ + "MMP_RUNS_DIR": str(tmp_path / "runs"), + "XDG_CONFIG_HOME": str(tmp_path / "xdg"), + }, + ) + assert p.returncode == 0 + data = json.loads(p.stdout) + for prov in data["providers"]: + assert "longform" in prov["media_types"] + + +def test_commit_writes_run_dir(tmp_path): + src = tmp_path / "m.yaml" + src.write_text( + 'schema_version: "0.2"\n' + "type: longform\n" + 'title: "X"\n' + 'body: "hi"\n' + "mode: dry-run\n" + "targets: [wechat-article]\n", + encoding="utf-8", + ) + p = _run( + "wizard", + "--commit", + str(src), + env_extra={ + "MMP_RUNS_DIR": str(tmp_path / "runs"), + "XDG_CONFIG_HOME": str(tmp_path / "xdg"), + }, + ) + assert p.returncode == 0, p.stderr + assert "RUN_DIR" in p.stdout + runs = list((tmp_path / "runs").iterdir()) + assert len(runs) == 1 + assert (runs[0] / "manifest.yaml").exists() + assert (runs[0] / "manifest.lock.json").exists() +``` + +- [ ] **Step 3: Run test (should pass)** + +Run: `pytest tests/integration/test_wizard_cli.py -v` +Expected: PASS — 3 passed. + +- [ ] **Step 4: Commit** + +```bash +git add scripts/mmp.py tests/integration/test_wizard_cli.py +git commit -m "feat(cli): wire mmp wizard --dump-context and --commit" +``` + +--- + +## Task 10: Update `cmd_setup` to Reference Wizard Prompt + +**Files:** +- Modify: `scripts/mmp.py` + +- [ ] **Step 1: Augment `cmd_setup` to mention the prompt fragment** + +Replace the existing `cmd_setup` opening lines: + +```python +def cmd_setup(args: argparse.Namespace) -> int: + from core.credentials import CredentialStore + from core.provider import ProviderRegistry + + reg = ProviderRegistry() + reg.discover() + try: + provider = reg.resolve(args.provider) + except Exception as e: + print(f"ERROR {e}", file=sys.stderr) + return 2 + + store = CredentialStore() + values: dict[str, str] = {} + print( + f"Configure {args.provider} (account: {args.account}). " + "Press Enter to skip a key.\n" + "(For Claude-driven setup, see core/wizard/credential_setup.md.)" + ) + for spec in provider.required_credentials: + prompt = f" {spec.key}" + if spec.description: + prompt += f" ({spec.description})" + if spec.setup_hint: + prompt += f" hint: {spec.setup_hint}" + prompt += ": " + if spec.secret: + import getpass + v = getpass.getpass(prompt) + else: + v = input(prompt) + if v: + values[spec.key] = v + if values: + store.set(args.provider, args.account, values) + print(f"OK saved {len(values)} keys to vault.") + else: + print("nothing to save.") + return 0 +``` + +- [ ] **Step 2: Quick manual sanity check** + +Run: `python3 scripts/mmp.py setup --help` +Expected: shows usage with `--account` option. + +- [ ] **Step 3: Commit** + +```bash +git add scripts/mmp.py +git commit -m "feat(cli): improve setup prompts; reference credential_setup.md" +``` + +--- + +## Task 11: SKILL.md — Add Wizard Triggers + Claude-Driven Flow + +**Files:** +- Modify: `SKILL.md` + +- [ ] **Step 1: Replace the SKILL.md `description:` and add a wizard section** + +In `SKILL.md` frontmatter, **append** the following triggers to the `description:` line so the router catches wizard intent: + +``` +... or "新发布", "帮我发一组到", "wizard", "guide me to publish" +``` + +Append a new section after the `Default mode = draft` block: + +```markdown +## Wizard Mode + +When the user says "新发布", "帮我发一组到 X / Y", "publish to ...", "cross-post", +or pastes content with publishing intent, run the **3-stage wizard** instead of +asking them to write a manifest: + +1. **Stage 1 — Source Extraction**: read `core/wizard/source_extraction.md` and + follow the instructions there. Extract `type`, `title`, `body`, `cover`, + `images`, `tags`, `cta` into your conversation memory. Don't write files yet. + +2. **Stage 2 — Target Selection**: read `core/wizard/target_selection.md`. Run + `python3 scripts/mmp.py wizard --dump-context --type ` to fetch available + providers + credential status + accounts. Ask which targets, modes, accounts. + +3. **Stage 3 — Manifest Assembly**: read `core/wizard/manifest_assembly.md`. + Render YAML, write to a temp file, validate via `python3 scripts/mmp.py validate`, + show the user, get approval. On approve, run `python3 scripts/mmp.py wizard --commit `. + +For setup credentials flows ("配置凭证", "setup wechat-article account"), read +`core/wizard/credential_setup.md`. Direct the user to run `mmp setup ` +locally — never ask them to paste a secret into chat unless they insist. + +## Public-publish Gate + +If a wizard run would result in `mode: publish` for any target, ALWAYS: + +1. Show the rendered manifest first. +2. Ask "Confirm public publish? (yes/no)". +3. Proceed only on exact match `yes`. Anything else → downgrade to `draft`. + +This rule overrides any earlier user permission. Each public publish is a fresh +ask in the active conversation. +``` + +- [ ] **Step 2: Commit** + +```bash +git add SKILL.md +git commit -m "docs(skill): add wizard mode and public-publish gate" +``` + +--- + +## Task 12: Update HANDOFF.md + +**Files:** +- Modify: `docs/HANDOFF.md` + +- [ ] **Step 1: Append Plan 2 status block** + +Append to `docs/HANDOFF.md`: + +```markdown + +### Plan 2 status (this commit range) + +- `core/wizard/` package: source_extraction / target_selection / manifest_assembly / credential_setup prompts +- `core/wizard/loader.py`: render Markdown fragments with {{var}} substitution +- `core/wizard/context.py`: dump providers/accounts/settings as JSON for Claude +- `core/wizard/commit.py`: validate + persist a manifest into a new run dir +- `core/settings.py`: read/write `~/.config/mmp/settings.toml` +- CLI: `mmp wizard --dump-context [--type ...]`, `mmp wizard --commit ` +- SKILL.md: wizard triggers + 3-stage flow + public-publish gate + +### Open items after Plan 2 + +- Remaining 4 providers (xiaohongshu / wechat_image / x_article / substack): Plan 3 +- Plugin marketplace prep + CI: Plan 4 +``` + +- [ ] **Step 2: Commit** + +```bash +git add docs/HANDOFF.md +git commit -m "docs(handoff): note Plan 2 progress" +``` + +--- + +## Task 13: Final Lint + Test Pass + +- [ ] **Step 1: Run lint** + +Run: `python3 -m ruff check . && python3 -m ruff format --check .` +Expected: 0 issues. Fix anything reported. + +- [ ] **Step 2: Run typecheck** + +Run: `python3 -m mypy core` +Expected: 0 errors. + +- [ ] **Step 3: Run full test** + +Run: `make test` +Expected: all green; smoke prints `{"ok": true, ...}`. + +- [ ] **Step 4: Manually exercise wizard CLI** + +```bash +python3 scripts/mmp.py wizard --dump-context | head -40 +``` + +Expected: JSON output with `providers`, `accounts`, `settings` keys. + +- [ ] **Step 5: Commit any cleanup** + +```bash +git add -A +git diff --cached --quiet || git commit -m "chore(plan-2): final cleanup" +``` + +--- + +## Self-Review Checklist + +- [ ] All tasks 1–13 committed +- [ ] `make test` green +- [ ] `mmp wizard --dump-context` returns valid JSON +- [ ] All four prompt fragments are non-empty real prompts (not placeholders) +- [ ] Spec sections covered: §7 (Wizard Flow), §6.4 (Setup wizard) +- [ ] Spec items deferred: §10.1 remaining providers (Plan 3), §9 + §11 (Plan 4) + +## Hand-off to Plan 3 + +Plan 3 will: +- Migrate `xiaohongshu`, `wechat_image`, `x_article`, `substack` providers to the same contract +- Replace `prepare_image_post.py` / `prepare_longform.py` / `execute_image_post.py` with thin deprecation shims +- Extend integration tests diff --git a/docs/superpowers/plans/2026-05-05-plan-3-remaining-providers.md b/docs/superpowers/plans/2026-05-05-plan-3-remaining-providers.md new file mode 100644 index 0000000..1b8cf0b --- /dev/null +++ b/docs/superpowers/plans/2026-05-05-plan-3-remaining-providers.md @@ -0,0 +1,1785 @@ +# Plan 3 — Remaining 4 Provider Migrations + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Migrate `xiaohongshu`, `wechat_image`, `x_article`, `substack` from the legacy `prepare_*`/`execute_*` scripts to the v0.2 `Provider` contract; deprecate old scripts as thin shims; extend integration coverage. + +**Architecture:** Each provider becomes a directory under `providers/` with `provider.yaml`, `rules.py`, `provider.py`, `tests/`. Logic from `scripts/prepare_image_post.py`, `scripts/prepare_longform.py`, `scripts/execute_image_post.py`, `scripts/adapt_content.py` is split out per provider. Real-connector status varies: `xiaohongshu` keeps local-draft via `xiaohongshu/scripts/draft.sh`; `wechat_image` keeps browser-flow guide; `x_article`/`substack` remain payload-only (stub `execute`). + +**Tech Stack:** Same as Plan 1/2. + +**Spec reference:** `docs/superpowers/specs/2026-05-05-multi-media-publisher-redesign-design.md` §10 (Bundled Provider Migration). + +**Depends on:** Plan 1 + Plan 2 complete. `Provider`, `ProviderRegistry`, `Manifest`, `CredentialStore`, `Run`, `Wizard` already in place. + +--- + +## File Structure + +**Created:** + +``` +providers/xiaohongshu/__init__.py +providers/xiaohongshu/provider.yaml +providers/xiaohongshu/rules.py +providers/xiaohongshu/provider.py +providers/xiaohongshu/tests/__init__.py +providers/xiaohongshu/tests/test_provider.py + +providers/wechat_image/__init__.py +providers/wechat_image/provider.yaml +providers/wechat_image/rules.py +providers/wechat_image/provider.py +providers/wechat_image/tests/__init__.py +providers/wechat_image/tests/test_provider.py +providers/wechat_image/notes.md # (moved from references/wechat-image-calibration.md) + +providers/x_article/__init__.py +providers/x_article/provider.yaml +providers/x_article/rules.py +providers/x_article/provider.py +providers/x_article/tests/__init__.py +providers/x_article/tests/test_provider.py + +providers/substack/__init__.py +providers/substack/provider.yaml +providers/substack/rules.py +providers/substack/provider.py +providers/substack/tests/__init__.py +providers/substack/tests/test_provider.py + +tests/fixtures/image-post-xhs.yaml +tests/fixtures/image-post-xhs.body.md +tests/fixtures/longform-multi.yaml +tests/integration/test_image_post_e2e.py +tests/integration/test_longform_multi_e2e.py +``` + +**Modified (deprecation shims):** + +``` +scripts/prepare_image_post.py # thin shim → mmp publish +scripts/prepare_longform.py # thin shim → mmp publish +scripts/execute_image_post.py # thin shim → mmp publish +scripts/adapt_content.py # thin shim → mmp publish +scripts/publish_manifest.py # thin shim → mmp validate +``` + +**Modified:** + +``` +SKILL.md # provider table updated to ✓ for all 5 providers +docs/HANDOFF.md # Plan 3 status note +scripts/test_local.py # smoke covers all 5 providers in dry-run +references/wechat-image-calibration.md # moved to providers/wechat_image/notes.md +references/wechat-api-provider.md # moved to providers/wechat_article/notes.md +``` + +--- + +## Task 1: `xiaohongshu` Provider — Scaffold + Rules + +**Files:** +- Create: `providers/xiaohongshu/__init__.py` +- Create: `providers/xiaohongshu/provider.yaml` +- Create: `providers/xiaohongshu/rules.py` +- Create: `providers/xiaohongshu/tests/__init__.py` + +- [ ] **Step 1: Create scaffold dirs** + +```bash +mkdir -p providers/xiaohongshu/tests +touch providers/xiaohongshu/__init__.py providers/xiaohongshu/tests/__init__.py +``` + +- [ ] **Step 2: Write `provider.yaml`** + +```yaml +name: xiaohongshu +display_name: 小红书 +media_types: + - image-post + - video-post +capabilities: + draft: true + publish: false + schedule: false +required_credentials: + - key: XHS_COOKIE_PATH + description: "Path to xiaohongshu cookies file (JSON)" + secret: false + setup_hint: "Use the xiaohongshu skill's `xhs-login` flow to capture cookies; default path `~/.config/mmp/cookies/xhs.json`" +entry: provider:XiaohongshuProvider +schema_version: 1 +``` + +- [ ] **Step 3: Write `rules.py`** + +```python +"""Platform rules for Xiaohongshu image posts. + +Sources (current MCP docs): +- title <= 20 chars +- body (caption) <= 1000 chars +- images 1..9 +- tags max 10 (soft warning above) +""" + +from __future__ import annotations + +from core.rules import PlatformRules + +XHS_RULES = PlatformRules( + title_max=20, + body_max=1000, + image_count_min=1, + image_count_max=9, + tag_max=10, +) +``` + +- [ ] **Step 4: Commit** + +```bash +git add providers/xiaohongshu/ +git commit -m "feat(xiaohongshu): scaffold provider yaml + rules" +``` + +--- + +## Task 2: `xiaohongshu` Provider — validate + prepare + execute (local draft) + +**Files:** +- Create: `providers/xiaohongshu/provider.py` +- Create: `providers/xiaohongshu/tests/test_provider.py` + +- [ ] **Step 1: Write failing test** + +`providers/xiaohongshu/tests/test_provider.py`: + +```python +import json +import os +from pathlib import Path +from unittest.mock import patch + +import pytest + +from core.manifest import Manifest, Target +from providers.xiaohongshu.provider import XiaohongshuProvider + + +@pytest.fixture +def img_manifest(tmp_path): + img1 = tmp_path / "01.png" + img2 = tmp_path / "02.png" + img1.write_bytes(b"png1") + img2.write_bytes(b"png2") + return Manifest( + schema_version="0.2", + type="image-post", + title="短标题", + body="这是一段不超过 1000 字的图文 caption。", + mode="dry-run", + targets=[Target(name="xiaohongshu")], + images=[str(img1), str(img2)], + tags=["AI", "创业"], + ) + + +def test_validate_passes(img_manifest): + p = XiaohongshuProvider() + res = p.validate(img_manifest, img_manifest.targets[0]) + assert all(v.severity.value != "error" for v in res.violations) + + +def test_validate_title_too_long(img_manifest): + img_manifest.title = "这个标题肯定超过了二十个字符的小红书限制确实如此非常长" + p = XiaohongshuProvider() + res = p.validate(img_manifest, img_manifest.targets[0]) + assert any(v.code == "TITLE_TOO_LONG" for v in res.violations) + + +def test_validate_no_images(img_manifest): + img_manifest.images = [] + p = XiaohongshuProvider() + res = p.validate(img_manifest, img_manifest.targets[0]) + assert any(v.code == "IMAGE_COUNT_BELOW_MIN" for v in res.violations) + + +def test_prepare_writes_payload(img_manifest, tmp_path): + run_dir = tmp_path / "run" + run_dir.mkdir() + p = XiaohongshuProvider() + out = p.prepare(img_manifest, img_manifest.targets[0], run_dir) + assert out.payload_path.exists() + payload = json.loads(out.payload_path.read_text()) + assert payload["title"] == "短标题" + assert len(payload["images"]) == 2 + assert payload["tags"] == ["AI", "创业"] + + +def test_execute_dry_run(img_manifest, tmp_path): + run_dir = tmp_path / "run" + (run_dir / "packs" / "xiaohongshu").mkdir(parents=True) + p = XiaohongshuProvider() + p.prepare(img_manifest, img_manifest.targets[0], run_dir) + res = p.execute(run_dir, img_manifest.targets[0], mode="dry-run", credentials={}) + assert res.status == "ok" + assert res.mode_actual == "dry-run" + + +def test_execute_draft_invokes_local_script(img_manifest, tmp_path): + run_dir = tmp_path / "run" + (run_dir / "packs" / "xiaohongshu").mkdir(parents=True) + p = XiaohongshuProvider() + p.prepare(img_manifest, img_manifest.targets[0], run_dir) + + with patch( + "providers.xiaohongshu.provider._invoke_local_draft", + return_value={"draft_id": "xhs_local_abc"}, + ) as mock: + res = p.execute( + run_dir, + img_manifest.targets[0], + mode="draft", + credentials={"XHS_COOKIE_PATH": "/tmp/x"}, + ) + mock.assert_called_once() + assert res.status == "ok" + assert res.mode_actual == "draft-local" + assert res.external_id == "xhs_local_abc" + + +def test_execute_publish_refused(img_manifest, tmp_path): + run_dir = tmp_path / "run" + (run_dir / "packs" / "xiaohongshu").mkdir(parents=True) + p = XiaohongshuProvider() + p.prepare(img_manifest, img_manifest.targets[0], run_dir) + with pytest.raises(NotImplementedError, match="publish"): + p.execute( + run_dir, + img_manifest.targets[0], + mode="publish", + credentials={"XHS_COOKIE_PATH": "x"}, + ) +``` + +- [ ] **Step 2: Run test (should fail)** + +Run: `pytest providers/xiaohongshu/tests/test_provider.py -v` +Expected: FAIL — module not found. + +- [ ] **Step 3: Implement `provider.py`** + +```python +"""Xiaohongshu provider — local draft via the xiaohongshu skill's draft.sh. + +This v0.2 provider only knows the `draft-local` path (creates a local draft +file, no platform upload). Platform draft and publish are deferred. +""" + +from __future__ import annotations + +import json +import shutil +import subprocess +from pathlib import Path +from typing import Any + +from core.errors import ProviderExecutionError +from core.provider import ( + CredentialSpec, + ExecutionResult, + HealthStatus, + PreparedPayload, + Provider, + ValidationResult, +) +from providers.xiaohongshu.rules import XHS_RULES + + +def _invoke_local_draft(payload_path: Path, cookie_path: str) -> dict[str, Any]: + """Call the xiaohongshu skill's draft.sh. Returns parsed JSON output.""" + candidates = [ + Path.home() / ".openclaw" / "skills" / "xiaohongshu" / "scripts" / "draft.sh", + Path.home() / ".config" / "mmp" / "skills" / "xiaohongshu" / "scripts" / "draft.sh", + ] + script = next((c for c in candidates if c.exists()), None) + if script is None: + raise FileNotFoundError( + "xiaohongshu draft.sh not found in expected locations: " + + ", ".join(str(c) for c in candidates) + ) + result = subprocess.run( + [str(script), "--payload", str(payload_path), "--cookie", cookie_path], + capture_output=True, + text=True, + ) + if result.returncode != 0: + raise RuntimeError(f"draft.sh failed: {result.stderr}") + try: + return json.loads(result.stdout) + except json.JSONDecodeError: + return {"draft_id": f"xhs_local_{abs(hash(result.stdout)) % 10**9}"} + + +class XiaohongshuProvider(Provider): + name = "xiaohongshu" + display_name = "小红书" + media_types = ["image-post", "video-post"] + capabilities = {"draft": True, "publish": False, "schedule": False} + required_credentials = [ + CredentialSpec( + key="XHS_COOKIE_PATH", + description="Path to xiaohongshu cookies file", + secret=False, + setup_hint="Use xhs-login from the xiaohongshu skill", + ) + ] + platform_rules = XHS_RULES + + def validate(self, manifest: Any, target: Any) -> ValidationResult: + return ValidationResult(violations=self.platform_rules.lint(manifest, self.name)) + + def prepare(self, manifest: Any, target: Any, run_dir: Path) -> PreparedPayload: + pack_dir = run_dir / "packs" / self.name + pack_dir.mkdir(parents=True, exist_ok=True) + + payload = { + "title": manifest.title, + "caption": manifest.body, + "images": list(manifest.images or []), + "tags": list(manifest.tags or []), + "cta": manifest.cta, + "mode": target.mode, + "options": dict(target.options or {}), + } + payload_path = pack_dir / "payload.json" + payload_path.write_text(json.dumps(payload, indent=2, ensure_ascii=False), encoding="utf-8") + (pack_dir / "content.md").write_text(manifest.body or "", encoding="utf-8") + return PreparedPayload(pack_dir=pack_dir, payload_path=payload_path) + + def execute( + self, + run_dir: Path, + target: Any, + mode: str, + credentials: dict[str, str], + ) -> ExecutionResult: + if mode == "publish": + raise NotImplementedError( + "xiaohongshu publish path not enabled in v0.2; use mode=draft" + ) + if mode == "dry-run": + return ExecutionResult(status="ok", mode_actual="dry-run", external_id=None) + + cookie_path = credentials.get("XHS_COOKIE_PATH") + if not cookie_path: + raise ProviderExecutionError( + target=self.name, + step="auth", + upstream=ValueError("missing XHS_COOKIE_PATH"), + retryable=False, + ) + + payload_path = run_dir / "packs" / self.name / "payload.json" + try: + out = _invoke_local_draft(payload_path, cookie_path) + except Exception as exc: + raise ProviderExecutionError( + target=self.name, step="local_draft", upstream=exc, retryable=True + ) from exc + + return ExecutionResult( + status="ok", + mode_actual="draft-local", + external_id=out.get("draft_id"), + extras={"draft_path": out.get("draft_path")}, + ) + + def health_check(self, credentials: dict[str, str]) -> HealthStatus: + cookie_path = credentials.get("XHS_COOKIE_PATH") + if cookie_path and Path(cookie_path).exists(): + return HealthStatus.ok + return HealthStatus.failed +``` + +- [ ] **Step 4: Run test (should pass)** + +Run: `pytest providers/xiaohongshu/tests/test_provider.py -v` +Expected: PASS — 7 passed. + +- [ ] **Step 5: Commit** + +```bash +git add providers/xiaohongshu/provider.py providers/xiaohongshu/tests/test_provider.py +git commit -m "feat(xiaohongshu): implement provider with local draft via draft.sh" +``` + +--- + +## Task 3: `wechat_image` Provider — Scaffold + Rules + +**Files:** +- Create: `providers/wechat_image/__init__.py` +- Create: `providers/wechat_image/provider.yaml` +- Create: `providers/wechat_image/rules.py` +- Create: `providers/wechat_image/tests/__init__.py` +- Move: `references/wechat-image-calibration.md` → `providers/wechat_image/notes.md` + +- [ ] **Step 1: Create scaffold + move notes** + +```bash +mkdir -p providers/wechat_image/tests +touch providers/wechat_image/__init__.py providers/wechat_image/tests/__init__.py +git mv references/wechat-image-calibration.md providers/wechat_image/notes.md +``` + +- [ ] **Step 2: Write `provider.yaml`** + +```yaml +name: wechat-image +display_name: 微信图文内容 +media_types: + - image-post +capabilities: + draft: true + publish: false + schedule: false +required_credentials: [] +entry: provider:WeChatImageProvider +schema_version: 1 +``` + +- [ ] **Step 3: Write `rules.py`** + +```python +"""Platform rules for WeChat image posts (图文内容, not OA articles). + +Approximate limits (refine when browser flow lands): +- title up to ~64 chars +- caption up to ~600 chars +- images 1..9 +""" + +from __future__ import annotations + +from core.rules import PlatformRules + +WECHAT_IMAGE_RULES = PlatformRules( + title_max=64, + body_max=600, + image_count_min=1, + image_count_max=9, +) +``` + +- [ ] **Step 4: Commit** + +```bash +git add providers/wechat_image/ +git commit -m "feat(wechat_image): scaffold provider yaml + rules; move calibration notes" +``` + +--- + +## Task 4: `wechat_image` Provider — validate + prepare + execute (browser-flow guide) + +**Files:** +- Create: `providers/wechat_image/provider.py` +- Create: `providers/wechat_image/tests/test_provider.py` + +- [ ] **Step 1: Write failing test** + +`providers/wechat_image/tests/test_provider.py`: + +```python +import json +from pathlib import Path + +import pytest + +from core.manifest import Manifest, Target +from providers.wechat_image.provider import WeChatImageProvider + + +@pytest.fixture +def img_manifest(tmp_path): + img = tmp_path / "01.png" + img.write_bytes(b"png") + return Manifest( + schema_version="0.2", + type="image-post", + title="短", + body="caption text", + mode="dry-run", + targets=[Target(name="wechat-image")], + images=[str(img)], + ) + + +def test_validate(img_manifest): + p = WeChatImageProvider() + res = p.validate(img_manifest, img_manifest.targets[0]) + assert all(v.severity.value != "error" for v in res.violations) + + +def test_prepare_writes_payload(img_manifest, tmp_path): + run_dir = tmp_path / "run" + run_dir.mkdir() + p = WeChatImageProvider() + out = p.prepare(img_manifest, img_manifest.targets[0], run_dir) + assert out.payload_path.exists() + payload = json.loads(out.payload_path.read_text()) + assert payload["title"] == "短" + assert payload["caption"] == "caption text" + + +def test_execute_draft_writes_browser_flow_guide(img_manifest, tmp_path): + run_dir = tmp_path / "run" + (run_dir / "packs" / "wechat-image").mkdir(parents=True) + p = WeChatImageProvider() + p.prepare(img_manifest, img_manifest.targets[0], run_dir) + res = p.execute(run_dir, img_manifest.targets[0], mode="draft", credentials={}) + guide = run_dir / "packs" / "wechat-image" / "browser-flow.md" + assert guide.exists() + assert "mp.weixin.qq.com" in guide.read_text() + assert res.status == "ok" + assert res.mode_actual == "draft-local" + + +def test_execute_dry_run_skips_guide(img_manifest, tmp_path): + run_dir = tmp_path / "run" + (run_dir / "packs" / "wechat-image").mkdir(parents=True) + p = WeChatImageProvider() + p.prepare(img_manifest, img_manifest.targets[0], run_dir) + res = p.execute(run_dir, img_manifest.targets[0], mode="dry-run", credentials={}) + assert res.mode_actual == "dry-run" + + +def test_execute_publish_refused(img_manifest, tmp_path): + run_dir = tmp_path / "run" + (run_dir / "packs" / "wechat-image").mkdir(parents=True) + p = WeChatImageProvider() + p.prepare(img_manifest, img_manifest.targets[0], run_dir) + with pytest.raises(NotImplementedError, match="publish"): + p.execute(run_dir, img_manifest.targets[0], mode="publish", credentials={}) +``` + +- [ ] **Step 2: Run test (should fail)** + +Run: `pytest providers/wechat_image/tests/test_provider.py -v` +Expected: FAIL — module not found. + +- [ ] **Step 3: Implement `provider.py`** + +```python +"""WeChat 图文内容 provider — emits a browser-flow guide for the user. + +The browser path to mp.weixin.qq.com is currently blocked under OpenClaw policy, +so this provider does NOT automate the upload. Instead, it produces a +step-by-step Markdown guide the user follows in their own browser. +""" + +from __future__ import annotations + +import json +from pathlib import Path +from typing import Any + +from core.provider import ( + ExecutionResult, + HealthStatus, + PreparedPayload, + Provider, + ValidationResult, +) +from providers.wechat_image.rules import WECHAT_IMAGE_RULES + + +_GUIDE_TEMPLATE = """\ +# WeChat 图文内容 — 手动执行指南 + +> 自动化未启用:浏览器对 mp.weixin.qq.com 的访问被策略阻止。请按下面的步骤手动完成。 + +## Payload + +文件:`{payload_path}` + +字段: + +- **标题**:`{title}` +- **正文**:见 `content.md` +- **图片**({n_images} 张): +{image_list} +- **CTA**:{cta} + +## 操作步骤 + +1. 打开 https://mp.weixin.qq.com/ 并登录目标公众号。 +2. 顶部菜单选择 **图文素材** → **新建图文素材**(图文内容)。 +3. 填入标题、摘要(如有)。 +4. 把上面列出的每张图片按顺序上传。 +5. 把 `content.md` 内容粘贴到正文。 +6. 点击 **保存草稿**。**不要点发布**。 +7. 回到这里继续后续动作或确认草稿状态。 + +## 安全提示 + +- 不要绕过登录或验证码。 +- 不要导出 cookie 文件到任何外部位置。 +- 草稿确认后,如需公开发布,使用公众号原生的"群发"功能。 +""" + + +class WeChatImageProvider(Provider): + name = "wechat-image" + display_name = "微信图文内容" + media_types = ["image-post"] + capabilities = {"draft": True, "publish": False, "schedule": False} + required_credentials = [] + platform_rules = WECHAT_IMAGE_RULES + + def validate(self, manifest: Any, target: Any) -> ValidationResult: + return ValidationResult(violations=self.platform_rules.lint(manifest, self.name)) + + def prepare(self, manifest: Any, target: Any, run_dir: Path) -> PreparedPayload: + pack_dir = run_dir / "packs" / self.name + pack_dir.mkdir(parents=True, exist_ok=True) + payload = { + "title": manifest.title, + "caption": manifest.body, + "images": list(manifest.images or []), + "cta": manifest.cta, + "mode": target.mode, + "options": dict(target.options or {}), + } + payload_path = pack_dir / "payload.json" + payload_path.write_text(json.dumps(payload, indent=2, ensure_ascii=False), encoding="utf-8") + (pack_dir / "content.md").write_text(manifest.body or "", encoding="utf-8") + return PreparedPayload(pack_dir=pack_dir, payload_path=payload_path) + + def execute( + self, + run_dir: Path, + target: Any, + mode: str, + credentials: dict[str, str], + ) -> ExecutionResult: + if mode == "publish": + raise NotImplementedError("wechat-image publish path not supported in v0.2") + if mode == "dry-run": + return ExecutionResult(status="ok", mode_actual="dry-run", external_id=None) + + # mode == draft → write a browser-flow guide + pack_dir = run_dir / "packs" / self.name + payload_path = pack_dir / "payload.json" + payload = json.loads(payload_path.read_text(encoding="utf-8")) + image_list = "\n".join(f" - `{img}`" for img in payload["images"]) or " - (none)" + guide = _GUIDE_TEMPLATE.format( + payload_path=str(payload_path), + title=payload["title"], + n_images=len(payload["images"]), + image_list=image_list, + cta=payload.get("cta") or "(none)", + ) + guide_path = pack_dir / "browser-flow.md" + guide_path.write_text(guide, encoding="utf-8") + return ExecutionResult( + status="ok", + mode_actual="draft-local", + external_id=None, + extras={"guide_path": str(guide_path)}, + ) + + def health_check(self, credentials: dict[str, str]) -> HealthStatus: + return HealthStatus.unknown +``` + +- [ ] **Step 4: Run test (should pass)** + +Run: `pytest providers/wechat_image/tests/test_provider.py -v` +Expected: PASS — 5 passed. + +- [ ] **Step 5: Commit** + +```bash +git add providers/wechat_image/provider.py providers/wechat_image/tests/test_provider.py +git commit -m "feat(wechat_image): implement provider with browser-flow guide draft" +``` + +--- + +## Task 5: `x_article` Provider — Scaffold + Rules + Provider + +**Files:** +- Create: `providers/x_article/__init__.py` +- Create: `providers/x_article/provider.yaml` +- Create: `providers/x_article/rules.py` +- Create: `providers/x_article/provider.py` +- Create: `providers/x_article/tests/__init__.py` +- Create: `providers/x_article/tests/test_provider.py` + +- [ ] **Step 1: Create scaffold** + +```bash +mkdir -p providers/x_article/tests +touch providers/x_article/__init__.py providers/x_article/tests/__init__.py +``` + +- [ ] **Step 2: Write `provider.yaml`** + +```yaml +name: x-article +display_name: X Articles +media_types: + - longform +capabilities: + draft: true + publish: false + schedule: false +required_credentials: + - key: X_AUTH_TOKEN + description: "X (Twitter) auth token cookie value" + secret: true + setup_hint: "Capture from a logged-in browser session; rotate after testing" +entry: provider:XArticleProvider +schema_version: 1 +``` + +- [ ] **Step 3: Write `rules.py`** + +```python +"""Platform rules for X Articles. + +Approximate (X Articles are evolving): +- title <=70 chars practical +- body <=25000 chars +- cover optional +""" + +from __future__ import annotations + +from core.rules import PlatformRules + +X_ARTICLE_RULES = PlatformRules( + title_max=70, + body_max=25000, +) +``` + +- [ ] **Step 4: Write failing test** + +`providers/x_article/tests/test_provider.py`: + +```python +import json +from pathlib import Path + +import pytest + +from core.manifest import Manifest, Target +from providers.x_article.provider import XArticleProvider + + +@pytest.fixture +def article(tmp_path): + return Manifest( + schema_version="0.2", + type="longform", + title="An X Article", + body="# Heading\n\nBody.", + mode="dry-run", + targets=[Target(name="x-article")], + summary="A summary", + tags=["tech"], + ) + + +def test_validate(article): + p = XArticleProvider() + res = p.validate(article, article.targets[0]) + assert all(v.severity.value != "error" for v in res.violations) + + +def test_validate_title_too_long(article): + article.title = "x" * 200 + p = XArticleProvider() + res = p.validate(article, article.targets[0]) + assert any(v.code == "TITLE_TOO_LONG" for v in res.violations) + + +def test_prepare_writes_payload(article, tmp_path): + run_dir = tmp_path / "run" + run_dir.mkdir() + p = XArticleProvider() + out = p.prepare(article, article.targets[0], run_dir) + payload = json.loads(out.payload_path.read_text()) + assert payload["title"] == "An X Article" + assert payload["body"].startswith("# Heading") + + +def test_execute_dry_run(article, tmp_path): + run_dir = tmp_path / "run" + (run_dir / "packs" / "x-article").mkdir(parents=True) + p = XArticleProvider() + p.prepare(article, article.targets[0], run_dir) + res = p.execute(run_dir, article.targets[0], mode="dry-run", credentials={}) + assert res.mode_actual == "dry-run" + + +def test_execute_draft_returns_stub(article, tmp_path): + """v0.2: no real X connector. Draft falls back to dry-run-like result with TODO note.""" + run_dir = tmp_path / "run" + (run_dir / "packs" / "x-article").mkdir(parents=True) + p = XArticleProvider() + p.prepare(article, article.targets[0], run_dir) + res = p.execute( + run_dir, + article.targets[0], + mode="draft", + credentials={"X_AUTH_TOKEN": "stub"}, + ) + assert res.mode_actual == "dry-run" + assert res.extras.get("connector_status") == "not-implemented" + + +def test_execute_publish_refused(article, tmp_path): + run_dir = tmp_path / "run" + (run_dir / "packs" / "x-article").mkdir(parents=True) + p = XArticleProvider() + p.prepare(article, article.targets[0], run_dir) + with pytest.raises(NotImplementedError, match="publish"): + p.execute(run_dir, article.targets[0], mode="publish", credentials={}) +``` + +- [ ] **Step 5: Run test (should fail)** + +Run: `pytest providers/x_article/tests/test_provider.py -v` +Expected: FAIL — module not found. + +- [ ] **Step 6: Implement `provider.py`** + +```python +"""X Articles provider — payload-only stub. + +A real connector (likely the `x-articles` skill or browser automation) is not +shipped in v0.2. `execute` writes a TODO marker into the run dir and returns +mode_actual=dry-run, so multi-target manifests can still progress past this +target without failing the run. +""" + +from __future__ import annotations + +import json +from pathlib import Path +from typing import Any + +from core.provider import ( + CredentialSpec, + ExecutionResult, + HealthStatus, + PreparedPayload, + Provider, + ValidationResult, +) +from providers.x_article.rules import X_ARTICLE_RULES + + +class XArticleProvider(Provider): + name = "x-article" + display_name = "X Articles" + media_types = ["longform"] + capabilities = {"draft": True, "publish": False, "schedule": False} + required_credentials = [ + CredentialSpec( + key="X_AUTH_TOKEN", + description="X (Twitter) auth token", + secret=True, + ) + ] + platform_rules = X_ARTICLE_RULES + + def validate(self, manifest: Any, target: Any) -> ValidationResult: + return ValidationResult(violations=self.platform_rules.lint(manifest, self.name)) + + def prepare(self, manifest: Any, target: Any, run_dir: Path) -> PreparedPayload: + pack_dir = run_dir / "packs" / self.name + pack_dir.mkdir(parents=True, exist_ok=True) + payload = { + "title": manifest.title, + "body": manifest.body, + "summary": manifest.summary, + "cover": manifest.cover, + "tags": list(manifest.tags or []), + "mode": target.mode, + "options": dict(target.options or {}), + } + payload_path = pack_dir / "payload.json" + payload_path.write_text(json.dumps(payload, indent=2, ensure_ascii=False), encoding="utf-8") + (pack_dir / "content.md").write_text(manifest.body or "", encoding="utf-8") + return PreparedPayload(pack_dir=pack_dir, payload_path=payload_path) + + def execute( + self, + run_dir: Path, + target: Any, + mode: str, + credentials: dict[str, str], + ) -> ExecutionResult: + if mode == "publish": + raise NotImplementedError("x-article publish path not enabled in v0.2") + if mode == "dry-run": + return ExecutionResult(status="ok", mode_actual="dry-run", external_id=None) + + # mode == draft, no real connector yet + pack_dir = run_dir / "packs" / self.name + (pack_dir / "TODO-connector.md").write_text( + "# x-article connector not implemented in v0.2\n\n" + "Payload is ready at `payload.json`. To complete the draft:\n" + "1. Open https://x.com/i/articles/compose in a logged-in browser\n" + "2. Paste title from payload.title\n" + "3. Paste body from content.md\n" + "4. Set cover from payload.cover (if present)\n" + "5. Save Draft\n", + encoding="utf-8", + ) + return ExecutionResult( + status="ok", + mode_actual="dry-run", + external_id=None, + extras={"connector_status": "not-implemented"}, + ) + + def health_check(self, credentials: dict[str, str]) -> HealthStatus: + return HealthStatus.unknown +``` + +- [ ] **Step 7: Run test (should pass)** + +Run: `pytest providers/x_article/tests/test_provider.py -v` +Expected: PASS — 6 passed. + +- [ ] **Step 8: Commit** + +```bash +git add providers/x_article/ +git commit -m "feat(x_article): implement provider as payload-only stub" +``` + +--- + +## Task 6: `substack` Provider — Scaffold + Rules + Provider + +**Files:** +- Create: `providers/substack/*` (mirroring x_article structure) + +- [ ] **Step 1: Create scaffold** + +```bash +mkdir -p providers/substack/tests +touch providers/substack/__init__.py providers/substack/tests/__init__.py +``` + +- [ ] **Step 2: Write `provider.yaml`** + +```yaml +name: substack +display_name: Substack +media_types: + - longform +capabilities: + draft: true + publish: false + schedule: false +required_credentials: + - key: SUBSTACK_SESSION_COOKIE + description: "Substack session cookie value" + secret: true + setup_hint: "Capture `substack.sid` from a logged-in browser session" +entry: provider:SubstackProvider +schema_version: 1 +``` + +- [ ] **Step 3: Write `rules.py`** + +```python +"""Platform rules for Substack posts. + +- title <=100 chars practical +- subtitle <=200 chars +- body <=50000 chars +- cover optional +""" + +from __future__ import annotations + +from core.rules import PlatformRules, Severity, Violation + + +def _subtitle_lint(manifest, target_name: str) -> list[Violation]: + subtitle = (manifest.metadata or {}).get("subtitle") or "" + if subtitle and len(subtitle) > 200: + return [ + Violation( + code="SUBSTACK_SUBTITLE_TOO_LONG", + message=f"subtitle length {len(subtitle)} exceeds 200", + target=target_name, + field_path="metadata.subtitle", + severity=Severity.warning, + ) + ] + return [] + + +SUBSTACK_RULES = PlatformRules( + title_max=100, + body_max=50000, + extra_lints=[_subtitle_lint], +) +``` + +- [ ] **Step 4: Write failing test** + +`providers/substack/tests/test_provider.py`: + +```python +import json +from pathlib import Path + +import pytest + +from core.manifest import Manifest, Target +from providers.substack.provider import SubstackProvider + + +@pytest.fixture +def article(): + return Manifest( + schema_version="0.2", + type="longform", + title="A Substack Post", + body="Body text here.", + mode="dry-run", + targets=[Target(name="substack")], + metadata={"subtitle": "An optional subtitle"}, + ) + + +def test_validate_passes(article): + p = SubstackProvider() + res = p.validate(article, article.targets[0]) + assert all(v.severity.value != "error" for v in res.violations) + + +def test_validate_subtitle_too_long(article): + article.metadata["subtitle"] = "x" * 250 + p = SubstackProvider() + res = p.validate(article, article.targets[0]) + codes = [v.code for v in res.violations] + assert "SUBSTACK_SUBTITLE_TOO_LONG" in codes + + +def test_prepare_writes_payload(article, tmp_path): + run_dir = tmp_path / "run" + run_dir.mkdir() + p = SubstackProvider() + out = p.prepare(article, article.targets[0], run_dir) + payload = json.loads(out.payload_path.read_text()) + assert payload["title"] == "A Substack Post" + assert payload["subtitle"] == "An optional subtitle" + + +def test_execute_draft_returns_stub(article, tmp_path): + run_dir = tmp_path / "run" + (run_dir / "packs" / "substack").mkdir(parents=True) + p = SubstackProvider() + p.prepare(article, article.targets[0], run_dir) + res = p.execute( + run_dir, + article.targets[0], + mode="draft", + credentials={"SUBSTACK_SESSION_COOKIE": "stub"}, + ) + assert res.mode_actual == "dry-run" + assert res.extras.get("connector_status") == "not-implemented" + + +def test_execute_publish_refused(article, tmp_path): + run_dir = tmp_path / "run" + (run_dir / "packs" / "substack").mkdir(parents=True) + p = SubstackProvider() + p.prepare(article, article.targets[0], run_dir) + with pytest.raises(NotImplementedError, match="publish"): + p.execute(run_dir, article.targets[0], mode="publish", credentials={}) +``` + +- [ ] **Step 5: Implement `provider.py`** + +```python +"""Substack provider — payload-only stub. + +A real connector (substack-autopilot or generic browser) is not shipped in +v0.2. Behavior parallels x_article: payload + TODO marker + dry-run-like +result. +""" + +from __future__ import annotations + +import json +from pathlib import Path +from typing import Any + +from core.provider import ( + CredentialSpec, + ExecutionResult, + HealthStatus, + PreparedPayload, + Provider, + ValidationResult, +) +from providers.substack.rules import SUBSTACK_RULES + + +class SubstackProvider(Provider): + name = "substack" + display_name = "Substack" + media_types = ["longform"] + capabilities = {"draft": True, "publish": False, "schedule": False} + required_credentials = [ + CredentialSpec( + key="SUBSTACK_SESSION_COOKIE", + description="Substack session cookie", + secret=True, + ) + ] + platform_rules = SUBSTACK_RULES + + def validate(self, manifest: Any, target: Any) -> ValidationResult: + return ValidationResult(violations=self.platform_rules.lint(manifest, self.name)) + + def prepare(self, manifest: Any, target: Any, run_dir: Path) -> PreparedPayload: + pack_dir = run_dir / "packs" / self.name + pack_dir.mkdir(parents=True, exist_ok=True) + meta = manifest.metadata or {} + payload = { + "title": manifest.title, + "subtitle": meta.get("subtitle"), + "body": manifest.body, + "cover": manifest.cover, + "tags": list(manifest.tags or []), + "mode": target.mode, + "options": dict(target.options or {}), + } + payload_path = pack_dir / "payload.json" + payload_path.write_text(json.dumps(payload, indent=2, ensure_ascii=False), encoding="utf-8") + (pack_dir / "content.md").write_text(manifest.body or "", encoding="utf-8") + return PreparedPayload(pack_dir=pack_dir, payload_path=payload_path) + + def execute( + self, + run_dir: Path, + target: Any, + mode: str, + credentials: dict[str, str], + ) -> ExecutionResult: + if mode == "publish": + raise NotImplementedError("substack publish path not enabled in v0.2") + if mode == "dry-run": + return ExecutionResult(status="ok", mode_actual="dry-run", external_id=None) + + pack_dir = run_dir / "packs" / self.name + (pack_dir / "TODO-connector.md").write_text( + "# substack connector not implemented in v0.2\n\n" + "Payload is ready at `payload.json`. To complete:\n" + "1. Open https://substack.com/dashboard in a logged-in browser\n" + "2. Click 'New post'\n" + "3. Paste title and subtitle from payload\n" + "4. Paste body from content.md\n" + "5. Set cover from payload.cover (if present)\n" + "6. Save Draft\n", + encoding="utf-8", + ) + return ExecutionResult( + status="ok", + mode_actual="dry-run", + external_id=None, + extras={"connector_status": "not-implemented"}, + ) + + def health_check(self, credentials: dict[str, str]) -> HealthStatus: + return HealthStatus.unknown +``` + +- [ ] **Step 6: Run tests (should pass)** + +Run: `pytest providers/substack/tests/test_provider.py -v` +Expected: PASS — 5 passed. + +- [ ] **Step 7: Commit** + +```bash +git add providers/substack/ +git commit -m "feat(substack): implement provider as payload-only stub" +``` + +--- + +## Task 7: Integration Test — image-post (xhs + wechat-image) + +**Files:** +- Create: `tests/fixtures/image-post-multi.body.md` +- Create: `tests/fixtures/image-post-multi.yaml` +- Create: `tests/fixtures/img-01.png`, `img-02.png` +- Create: `tests/integration/test_image_post_e2e.py` + +- [ ] **Step 1: Create fixtures** + +```bash +python3 -c "import struct; \ +open('tests/fixtures/img-01.png','wb').write(bytes.fromhex('89504e470d0a1a0a0000000d49484452000000010000000108020000009077533de0000000016352474200aece1ce90000000c4944415478da6300010000050001a5f645400000000049454e44ae426082')); \ +open('tests/fixtures/img-02.png','wb').write(bytes.fromhex('89504e470d0a1a0a0000000d49484452000000010000000108020000009077533de0000000016352474200aece1ce90000000c4944415478da6300010000050001a5f645400000000049454e44ae426082'))" +``` + +`tests/fixtures/image-post-multi.body.md`: + +```markdown +这是一组图文 caption。 +不超过 1000 字。 +``` + +`tests/fixtures/image-post-multi.yaml`: + +```yaml +schema_version: "0.2" +type: image-post +title: "AI 创业的三个误区" +body: ./image-post-multi.body.md +mode: dry-run +language: zh-CN +targets: + - xiaohongshu + - wechat-image +assets: + images: + - ./img-01.png + - ./img-02.png +tags: + - AI + - 创业 +``` + +- [ ] **Step 2: Write integration test** + +`tests/integration/test_image_post_e2e.py`: + +```python +import json +import os +import subprocess +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parent.parent.parent +FIXTURE = ROOT / "tests" / "fixtures" / "image-post-multi.yaml" + + +def test_image_post_dry_run_both_targets(tmp_path): + p = subprocess.run( + [sys.executable, str(ROOT / "scripts" / "mmp.py"), "publish", str(FIXTURE)], + capture_output=True, + text=True, + env={**os.environ, "MMP_RUNS_DIR": str(tmp_path / "runs")}, + ) + assert p.returncode == 0, p.stderr + + runs = list((tmp_path / "runs").iterdir()) + rd = runs[0] + result = json.loads((rd / "result.json").read_text()) + names = [t["name"] for t in result["targets"]] + assert "xiaohongshu" in names + assert "wechat-image" in names + for t in result["targets"]: + assert t["status"] == "ok" + assert t["mode_actual"] == "dry-run" + + assert (rd / "packs" / "xiaohongshu" / "payload.json").exists() + assert (rd / "packs" / "wechat-image" / "payload.json").exists() +``` + +- [ ] **Step 3: Run test (should pass)** + +Run: `pytest tests/integration/test_image_post_e2e.py -v` +Expected: PASS — 1 passed. + +- [ ] **Step 4: Commit** + +```bash +git add tests/integration/test_image_post_e2e.py tests/fixtures/image-post-multi* tests/fixtures/img-*.png +git commit -m "test(integration): image-post dry-run covers xhs + wechat-image" +``` + +--- + +## Task 8: Integration Test — longform-multi (wechat-article + x-article + substack) + +**Files:** +- Create: `tests/fixtures/longform-multi.yaml` +- Create: `tests/fixtures/longform-multi.body.md` +- Create: `tests/fixtures/longform-cover.png` +- Create: `tests/integration/test_longform_multi_e2e.py` + +- [ ] **Step 1: Create fixtures** + +```bash +python3 -c "open('tests/fixtures/longform-cover.png','wb').write(bytes.fromhex('89504e470d0a1a0a0000000d49484452000000010000000108020000009077533de0000000016352474200aece1ce90000000c4944415478da6300010000050001a5f645400000000049454e44ae426082'))" +``` + +`tests/fixtures/longform-multi.body.md`: + +```markdown +# 一篇示例长文 + +正文段落。 +``` + +`tests/fixtures/longform-multi.yaml`: + +```yaml +schema_version: "0.2" +type: longform +title: "Example Longform" +body: ./longform-multi.body.md +mode: dry-run +language: zh-CN +targets: + - wechat-article + - x-article + - substack +assets: + cover: ./longform-cover.png +summary: "A quick summary." +metadata: + digest: "公众号摘要" + subtitle: "Substack subtitle" +tags: + - test +``` + +- [ ] **Step 2: Write integration test** + +`tests/integration/test_longform_multi_e2e.py`: + +```python +import json +import os +import subprocess +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parent.parent.parent +FIXTURE = ROOT / "tests" / "fixtures" / "longform-multi.yaml" + + +def test_longform_multi_dry_run(tmp_path): + p = subprocess.run( + [sys.executable, str(ROOT / "scripts" / "mmp.py"), "publish", str(FIXTURE)], + capture_output=True, + text=True, + env={**os.environ, "MMP_RUNS_DIR": str(tmp_path / "runs")}, + ) + assert p.returncode == 0, p.stderr + + rd = next((tmp_path / "runs").iterdir()) + result = json.loads((rd / "result.json").read_text()) + names = sorted(t["name"] for t in result["targets"]) + assert names == ["substack", "wechat-article", "x-article"] + for t in result["targets"]: + assert t["status"] == "ok" + for sub in ["wechat-article", "x-article", "substack"]: + assert (rd / "packs" / sub / "payload.json").exists() +``` + +- [ ] **Step 3: Run test (should pass)** + +Run: `pytest tests/integration/test_longform_multi_e2e.py -v` +Expected: PASS — 1 passed. + +- [ ] **Step 4: Commit** + +```bash +git add tests/integration/test_longform_multi_e2e.py tests/fixtures/longform-multi* tests/fixtures/longform-cover.png +git commit -m "test(integration): longform multi-target dry-run e2e" +``` + +--- + +## Task 9: Deprecate Old Scripts as Thin Shims + +**Files:** +- Modify: `scripts/prepare_image_post.py` +- Modify: `scripts/prepare_longform.py` +- Modify: `scripts/execute_image_post.py` +- Modify: `scripts/adapt_content.py` +- Modify: `scripts/publish_manifest.py` +- Move: `references/wechat-api-provider.md` → `providers/wechat_article/notes.md` + +- [ ] **Step 1: Move the wechat-api-provider notes** + +```bash +git mv references/wechat-api-provider.md providers/wechat_article/notes.md +``` + +- [ ] **Step 2: Replace each script with a deprecation shim** + +`scripts/prepare_image_post.py`: + +```python +"""DEPRECATED in v0.2. Use `mmp publish `. + +Logic moved to providers/xiaohongshu/ and providers/wechat_image/. +This shim will be removed in v0.3. +""" + +from __future__ import annotations + +import sys +import warnings + + +def main() -> int: + warnings.warn( + "prepare_image_post.py is deprecated; use `python3 scripts/mmp.py publish `", + DeprecationWarning, + stacklevel=2, + ) + print( + "DEPRECATED. Run `python3 scripts/mmp.py publish ` instead.", + file=sys.stderr, + ) + return 1 + + +if __name__ == "__main__": + sys.exit(main()) +``` + +`scripts/prepare_longform.py`: + +```python +"""DEPRECATED in v0.2. Use `mmp publish `. + +Logic moved to providers/wechat_article/, providers/x_article/, providers/substack/. +This shim will be removed in v0.3. +""" + +from __future__ import annotations + +import sys +import warnings + + +def main() -> int: + warnings.warn( + "prepare_longform.py is deprecated; use `python3 scripts/mmp.py publish `", + DeprecationWarning, + stacklevel=2, + ) + print( + "DEPRECATED. Run `python3 scripts/mmp.py publish ` instead.", + file=sys.stderr, + ) + return 1 + + +if __name__ == "__main__": + sys.exit(main()) +``` + +`scripts/execute_image_post.py`: + +```python +"""DEPRECATED in v0.2. Use `mmp publish [--mode-override draft]`. + +Logic moved to providers/xiaohongshu/ and providers/wechat_image/. +This shim will be removed in v0.3. +""" + +from __future__ import annotations + +import sys +import warnings + + +def main() -> int: + warnings.warn( + "execute_image_post.py is deprecated; use " + "`python3 scripts/mmp.py publish --mode-override draft`", + DeprecationWarning, + stacklevel=2, + ) + print( + "DEPRECATED. Run `python3 scripts/mmp.py publish --mode-override draft` instead.", + file=sys.stderr, + ) + return 1 + + +if __name__ == "__main__": + sys.exit(main()) +``` + +`scripts/adapt_content.py`: + +```python +"""DEPRECATED in v0.2. Use `mmp publish `. + +Per-target pack scaffolding is now done by each provider's prepare() method. +This shim will be removed in v0.3. +""" + +from __future__ import annotations + +import sys +import warnings + + +def main() -> int: + warnings.warn( + "adapt_content.py is deprecated; use `python3 scripts/mmp.py publish `", + DeprecationWarning, + stacklevel=2, + ) + print( + "DEPRECATED. Run `python3 scripts/mmp.py publish ` instead.", + file=sys.stderr, + ) + return 1 + + +if __name__ == "__main__": + sys.exit(main()) +``` + +`scripts/publish_manifest.py`: + +```python +"""DEPRECATED in v0.2. Use `mmp validate ` or `mmp publish `. + +Manifest skeleton creation is now part of `mmp publish`. +This shim will be removed in v0.3. +""" + +from __future__ import annotations + +import sys +import warnings + + +def main() -> int: + warnings.warn( + "publish_manifest.py is deprecated; use `python3 scripts/mmp.py validate ` " + "or `python3 scripts/mmp.py publish `", + DeprecationWarning, + stacklevel=2, + ) + print( + "DEPRECATED. Run `python3 scripts/mmp.py validate ` instead.", + file=sys.stderr, + ) + return 1 + + +if __name__ == "__main__": + sys.exit(main()) +``` + +- [ ] **Step 3: Verify imports succeed** + +```bash +for s in prepare_image_post prepare_longform execute_image_post adapt_content publish_manifest; do + python3 -c "import importlib.util; spec=importlib.util.spec_from_file_location('s','scripts/${s}.py'); m=importlib.util.module_from_spec(spec); spec.loader.exec_module(m)" +done +``` + +Expected: all return exit 0 with no error. + +- [ ] **Step 4: Commit** + +```bash +git add scripts/prepare_image_post.py scripts/prepare_longform.py \ + scripts/execute_image_post.py scripts/adapt_content.py \ + scripts/publish_manifest.py providers/wechat_article/notes.md +git commit -m "refactor: deprecate v0.1 scripts as thin shims; move wechat notes" +``` + +--- + +## Task 10: Update SKILL.md Provider Table + smoke test + +**Files:** +- Modify: `SKILL.md` +- Modify: `scripts/test_local.py` + +- [ ] **Step 1: Replace the v0.2 supported providers table in `SKILL.md`** + +Replace the table with: + +```markdown +| Provider | Media | Mode support | +|---|---|---| +| `wechat-article` | longform | dry-run, draft (real WeChat API; needs AppID/AppSecret) | +| `xiaohongshu` | image-post (video planned) | dry-run, draft (local draft via xiaohongshu skill) | +| `wechat-image` | image-post | dry-run, draft (browser-flow guide) | +| `x-article` | longform | dry-run, draft (payload + TODO; no connector in v0.2) | +| `substack` | longform | dry-run, draft (payload + TODO; no connector in v0.2) | +``` + +- [ ] **Step 2: Extend `scripts/test_local.py` smoke** + +Edit `scripts/test_local.py` to also exercise the image-post and longform-multi fixtures. Replace the body of `main()`: + +```python +def main() -> int: + fixtures = [ + ROOT / "tests" / "fixtures" / "wechat-article-e2e.yaml", + ROOT / "tests" / "fixtures" / "image-post-multi.yaml", + ROOT / "tests" / "fixtures" / "longform-multi.yaml", + ] + with tempfile.TemporaryDirectory(prefix="mmp-smoke-") as tmp: + tmp_path = Path(tmp) + runs_dir = tmp_path / "runs" + env_extra = { + "MMP_RUNS_DIR": str(runs_dir), + "XDG_CONFIG_HOME": str(tmp_path / "xdg"), + } + + for fix in fixtures: + p = _run_mmp("validate", str(fix), env_extra=env_extra) + assert p.returncode == 0, f"validate failed for {fix}:\n{p.stderr}" + + p = _run_mmp("publish", str(fix), env_extra=env_extra) + assert p.returncode == 0, f"publish dry-run failed for {fix}:\n{p.stderr}" + + # doctor + list + p = _run_mmp("doctor", env_extra=env_extra) + assert p.returncode == 0 + p = _run_mmp("list", "providers", env_extra=env_extra) + assert p.returncode == 0 + for prov in ("wechat-article", "xiaohongshu", "wechat-image", "x-article", "substack"): + assert prov in p.stdout, f"{prov} missing from list" + + runs = sorted((runs_dir).iterdir()) + print(json.dumps({"ok": True, "tmp": str(tmp_path), "runs": [str(r) for r in runs]}, indent=2)) + return 0 +``` + +- [ ] **Step 3: Run smoke** + +Run: `make smoke` +Expected: prints `{"ok": true, "runs": [...]}` with 3 run dirs. + +- [ ] **Step 4: Commit** + +```bash +git add SKILL.md scripts/test_local.py +git commit -m "docs(skill): mark all 5 providers available; smoke covers all of them" +``` + +--- + +## Task 11: Update HANDOFF.md + +**Files:** +- Modify: `docs/HANDOFF.md` + +- [ ] **Step 1: Append Plan 3 status block** + +```markdown + +### Plan 3 status (this commit range) + +- All 4 remaining providers migrated: + - `xiaohongshu` (image-post + video-post): local draft via `xiaohongshu/scripts/draft.sh` + - `wechat-image` (image-post): browser-flow guide (mp.weixin.qq.com is policy-blocked) + - `x-article` (longform): payload-only stub; connector TODO + - `substack` (longform): payload-only stub; connector TODO +- Old scripts (`prepare_image_post.py`, `prepare_longform.py`, `execute_image_post.py`, + `adapt_content.py`, `publish_manifest.py`) deprecated as thin shims; remove in v0.3 +- Smoke test covers wechat-article + image-post-multi + longform-multi +- 5 providers visible in `mmp list providers` + +### Open items after Plan 3 + +- Plugin marketplace prep + dual-host distribution: Plan 4 +- CI matrix (GitHub Actions): Plan 4 +- Real WeChat / X / Substack account verification: see `docs/manual-verification.md` (Plan 4) +``` + +- [ ] **Step 2: Commit** + +```bash +git add docs/HANDOFF.md +git commit -m "docs(handoff): note Plan 3 completion" +``` + +--- + +## Task 12: Final Lint + Test Pass + +- [ ] **Step 1: Run lint** + +Run: `python3 -m ruff check . && python3 -m ruff format --check .` +Expected: 0 issues. + +- [ ] **Step 2: Run typecheck** + +Run: `python3 -m mypy core providers` +Expected: 0 errors. (Note: `providers/` was not in mypy `files` config in Plan 1; if errors flood, narrow to per-file or update config.) + +If providers cause noise, update `pyproject.toml`: + +```toml +[tool.mypy] +files = ["core", "providers"] +[[tool.mypy.overrides]] +module = "providers.*.internal.*" +ignore_errors = true +``` + +- [ ] **Step 3: Run full test** + +Run: `make test` +Expected: all green. + +- [ ] **Step 4: Commit any cleanup** + +```bash +git add -A +git diff --cached --quiet || git commit -m "chore(plan-3): final cleanup" +``` + +--- + +## Self-Review Checklist + +- [ ] All 12 tasks committed +- [ ] `make test` green +- [ ] `mmp list providers` shows 5 providers +- [ ] All 5 providers have test files in `providers//tests/` +- [ ] Old scripts in `scripts/` are now shims that exit 1 with deprecation warning +- [ ] `references/wechat-image-calibration.md` and `references/wechat-api-provider.md` moved to provider notes +- [ ] Spec sections covered: §10 (all 5 providers migrated) +- [ ] Items deferred: §9 (dual-host plugin manifest) + §11 (CI) → Plan 4 + +## Hand-off to Plan 4 + +Plan 4 will: +- Write `.claude-plugin/plugin.json` +- Polish SKILL.md for plugin marketplace submission +- Rewrite README.md as user-facing install + usage +- Create `docs/architecture.md`, `docs/provider-contract.md`, `docs/credentials.md`, `docs/safety-policy.md`, `docs/manual-verification.md` +- Add `.github/workflows/ci.yml` with matrix +- Bump version to 0.2.0 in pyproject + plugin.json + SKILL.md +- Decide whether to remove deprecated shims (probably keep through v0.2 → remove in v0.3) diff --git a/docs/superpowers/plans/2026-05-05-plan-4-distribution-ci.md b/docs/superpowers/plans/2026-05-05-plan-4-distribution-ci.md new file mode 100644 index 0000000..bc4c5f2 --- /dev/null +++ b/docs/superpowers/plans/2026-05-05-plan-4-distribution-ci.md @@ -0,0 +1,1162 @@ +# Plan 4 — Distribution + CI + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Make the project installable as a Claude Code plugin AND continue to work as an OpenClaw skill from the same source tree, plus stand up CI that exercises all providers in dry-run. + +**Architecture:** Add `.claude-plugin/plugin.json` for the CC marketplace; polish `SKILL.md` to be the canonical entry recognized by both hosts; consolidate user-facing docs from scattered `references/*.md` into a clean `docs/` set; ship `.github/workflows/ci.yml` with a 2-OS × 3-Python matrix that runs lint, typecheck, unit, and smoke (no real-account network). + +**Tech Stack:** Same as previous plans + GitHub Actions. + +**Spec reference:** `docs/superpowers/specs/2026-05-05-multi-media-publisher-redesign-design.md` §9 (Dual-Host Distribution), §11 (Testing & CI), §13 (Open Questions). + +**Depends on:** Plans 1, 2, 3 complete. + +--- + +## File Structure + +**Created:** + +``` +.claude-plugin/plugin.json +.github/workflows/ci.yml +docs/architecture.md +docs/provider-contract.md +docs/credentials.md +docs/safety-policy.md # promoted from references/publishing-policy.md +docs/manual-verification.md +CHANGELOG.md +``` + +**Modified:** + +``` +SKILL.md # final polish: dual-host description, version +README.md # full rewrite as install + quickstart +pyproject.toml # version 0.2.0 +docs/HANDOFF.md # Plan 4 completion + handoff to v0.3 +``` + +**Deleted (content moved to docs/ or providers/):** + +``` +references/candidate-skills.md # archived to docs/legacy-research.md +references/image-post-mvp.md # archived +references/phase2-audit.md # archived +references/workflows.md # superseded by docs/architecture.md +references/manifest-schema.md # superseded by docs/architecture.md schema section +references/platform-map.md # superseded by per-provider provider.yaml +references/publishing-policy.md # promoted to docs/safety-policy.md +``` + +--- + +## Task 1: Promote `references/publishing-policy.md` → `docs/safety-policy.md` + +**Files:** +- Move: `references/publishing-policy.md` → `docs/safety-policy.md` + +- [ ] **Step 1: Move and refresh content** + +```bash +git mv references/publishing-policy.md docs/safety-policy.md +``` + +Edit `docs/safety-policy.md` to ensure the v0.2 phrasing: + +```markdown +# Safety & Approval Policy + +multi-media-publisher will never circumvent platform safeguards or post +publicly without explicit confirmation in the active conversation. + +## Defaults + +- Manifest top-level `mode` defaults to `draft` if omitted by user. +- All providers ship with `capabilities.publish: false` in v0.2. +- The wizard's Stage 3 inserts an additional confirmation gate when any target + has `mode: publish`. + +## Hard rules + +1. **No public publish without active confirmation.** Even if the user + pre-authorized a publish in a prior conversation, ask again on this run. +2. **No bypass.** Never skip login flows, CAPTCHAs, platform reviews, or + anti-abuse checks. If the browser hits an ambiguous state, stop and ask. +3. **No secret leakage.** Credentials never appear in: + - `result.json` + - `publish-log.md` + - any git-tracked file + - any printed output beyond `(received)` confirmation +4. **No CLI-arg secrets.** Pass through ENV or vault. CLI args are visible in + `ps`. +5. **No silent failure.** A target that fails records `status: failed` with + the upstream error message; the run does not pretend success. +6. **No batch surprise.** Publishing N targets is N explicit confirmations + when at least one is `mode: publish`. + +## Vault & key rotation + +- Vault file: `~/.config/mmp/credentials.json.age` (mode 600). +- Vault key: `~/.config/mmp/age-key.txt` (mode 600). +- Rotate the vault key by: + 1. `mmp list accounts` to enumerate + 2. `mmp setup --account
` re-enters values + 3. delete the old vault file + 4. delete the old age-key +- ENV variables override vault for the current session; useful for CI. + +## Third-party providers + +- User-installed providers under `~/.config/mmp/providers//` are + arbitrary Python. Loading is gated: + - First load: `mmp` prompts the user "trust provider ``? (y/n)" + - On `y`, the name is added to `settings.toml.providers.trusted_user_providers` + - On `n`, skip with a warning. +- v0.2 does not implement signature verification. Treat untrusted providers as + unsafe. + +## Reporting + +If a provider is found to publish without confirmation or leak secrets, +treat as a P0 bug. Open an issue and disable the provider in `settings.toml` +until patched. +``` + +- [ ] **Step 2: Commit** + +```bash +git add docs/safety-policy.md +git commit -m "docs: promote publishing-policy → docs/safety-policy.md (v0.2 phrasing)" +``` + +--- + +## Task 2: `docs/architecture.md` + +**Files:** +- Create: `docs/architecture.md` + +- [ ] **Step 1: Write architecture overview** + +```markdown +# Architecture + +This is the user-facing architecture summary. The full design spec lives at +`docs/superpowers/specs/2026-05-05-multi-media-publisher-redesign-design.md`. + +## Three layers + +``` +Shell: SKILL.md + .claude-plugin/plugin.json + │ + ▼ +Core: core/ (host-agnostic Python) + │ + ▼ +Providers: providers// + ~/.config/mmp/providers// +``` + +- **Shell** is what Claude Code or OpenClaw load to know this skill exists. + It declares triggers and points at `scripts/mmp.py`. +- **Core** is `core/manifest.py`, `core/provider.py`, `core/credentials.py`, + `core/run.py`, `core/rules.py`, `core/host.py`, `core/errors.py`, + `core/wizard/`, `core/settings.py`. Nothing in core depends on a host. +- **Providers** ship one per platform: `providers/wechat_article/` etc. + Each declares `provider.yaml` + `provider.py` + `rules.py`. + +## Manifest schema (v0.2) + +See `docs/provider-contract.md` for full field list and lock-file format. + +```yaml +schema_version: "0.2" +type: image-post | longform | video-post +title: "..." +body: "inline or ./path.md" +mode: dry-run | draft | publish +language: zh-CN +defaults: # optional + account: default + options: {} +targets: + - # short form, inherits top mode + - target: # full form + mode: draft + account: lewis + options: + digest: "..." +assets: + cover: ./cover.png + images: [] + video: null +tags: [] +metadata: {} +``` + +## Run lifecycle + +``` +mmp publish manifest.yaml + → load + validate manifest + → create runs/-/ + manifest.lock.json + → for each target: + provider.validate (lint platform rules) + provider.prepare (write packs//) + provider.execute (dry-run | draft | publish) + record TARGET_DONE in publish-log.md + checkpoint after key steps + → finalize: write result.json +``` + +## Why this shape + +- One manifest, many providers — adding a platform is one directory, not six edits. +- Core is host-agnostic — same code works in Claude Code, OpenClaw, or `python3 mmp.py`. +- draft-first — capabilities default to draft only; publish requires explicit opt-in. +- Vault is shared across hosts — `~/.config/mmp/` is a single source of truth. +``` + +- [ ] **Step 2: Commit** + +```bash +git add docs/architecture.md +git commit -m "docs: add architecture.md user-facing overview" +``` + +--- + +## Task 3: `docs/provider-contract.md` + +**Files:** +- Create: `docs/provider-contract.md` + +- [ ] **Step 1: Write provider author guide** + +```markdown +# Writing a Provider + +A provider is a directory containing `provider.yaml` + a Python module that +implements the `Provider` ABC. + +## Where it lives + +- **Bundled** (first-party): `providers//` +- **User** (third-party): `~/.config/mmp/providers//` + +The directory name uses snake_case (Python module name). The +`provider.yaml.name` is the kebab-case identifier referenced in manifests +and pack folders. + +## Required files + +``` +/ +├── __init__.py +├── provider.yaml +├── provider.py +├── rules.py # optional: platform rules +└── tests/ # optional but encouraged +``` + +## `provider.yaml` + +```yaml +name: my-platform # kebab-case; appears in manifests +display_name: My Platform # human-readable +media_types: [longform] # subset of {image-post, longform, video-post} +capabilities: + draft: true + publish: false + schedule: false +required_credentials: + - key: MYPLATFORM_TOKEN + description: "API token" + secret: true + setup_hint: "Get one from https://..." +entry: provider:MyPlatformProvider # python_module:ClassName, relative to the dir +schema_version: 1 +``` + +## `provider.py` + +Implement `Provider` from `core.provider`. Methods you must define: + +```python +class MyPlatformProvider(Provider): + name = "my-platform" + display_name = "My Platform" + media_types = ["longform"] + capabilities = {"draft": True, "publish": False, "schedule": False} + required_credentials = [...] # CredentialSpec list + platform_rules = MY_RULES # PlatformRules instance + + def validate(self, manifest, target) -> ValidationResult: ... + def prepare(self, manifest, target, run_dir) -> PreparedPayload: ... + def execute(self, run_dir, target, mode, credentials) -> ExecutionResult: ... + def health_check(self, credentials) -> HealthStatus: ... # optional +``` + +### `validate(manifest, target) -> ValidationResult` + +- Run `self.platform_rules.lint(manifest, self.name)` and wrap in + `ValidationResult(violations=...)`. +- Optional: append your own checks beyond `PlatformRules`. + +### `prepare(manifest, target, run_dir) -> PreparedPayload` + +- Write `/packs//payload.json` with what your `execute` + step needs. +- Optional: also write `content.md`, screenshots, browser-flow guides. +- Return a `PreparedPayload(pack_dir=..., payload_path=...)`. + +### `execute(run_dir, target, mode, credentials) -> ExecutionResult` + +- `mode` is one of `dry-run`, `draft`, `publish`. +- For `dry-run`, do nothing real; return + `ExecutionResult(status="ok", mode_actual="dry-run")`. +- For `draft`, perform the platform-side draft action; return + `ExecutionResult(status="ok", mode_actual="draft-platform" | "draft-local", + external_id=...)`. +- For `publish`, raise `NotImplementedError` unless your provider explicitly + supports it AND you have re-confirmed with the user. + +Wrap upstream failures in `ProviderExecutionError(target=..., step=..., +upstream=exc, retryable=True/False)`. The framework writes a checkpoint and +allows `mmp resume`. + +### `health_check(credentials) -> HealthStatus` + +Return `HealthStatus.ok | failed | unknown`. Used by `mmp doctor` and the +wizard to surface "your token works" before a run starts. + +## `rules.py` + +```python +from core.rules import PlatformRules, Severity, Violation + +def _custom_lint(manifest, target_name): + # ... + return [Violation(code="MY_CHECK", message="...", severity=Severity.warning)] + +MY_RULES = PlatformRules( + title_max=100, + body_max=10000, + cover_required=False, + extra_lints=[_custom_lint], +) +``` + +## Trust model for user-installed providers + +User providers under `~/.config/mmp/providers/` are not loaded automatically. +On first discovery, `mmp` prompts: + +> Trust provider `my-platform` from `~/.config/mmp/providers/my_platform/`? (y/n) + +A `y` adds the name to `settings.toml.providers.trusted_user_providers`. + +## Testing + +Put tests under `/tests/`. Pytest auto-discovers them when run from +the project root. The reference shape: + +```python +import json +from core.manifest import Manifest, Target +from providers.my_platform.provider import MyPlatformProvider + +def test_validate_passes(): + p = MyPlatformProvider() + m = Manifest(...) + res = p.validate(m, m.targets[0]) + assert all(v.severity.value != "error" for v in res.violations) +``` +``` + +- [ ] **Step 2: Commit** + +```bash +git add docs/provider-contract.md +git commit -m "docs: add provider authoring guide" +``` + +--- + +## Task 4: `docs/credentials.md` + +**Files:** +- Create: `docs/credentials.md` + +- [ ] **Step 1: Write credentials guide** + +```markdown +# Credentials & Vault + +multi-media-publisher stores per-provider credentials in an age-encrypted +JSON file and never exposes them in result/log/output. + +## File layout + +``` +~/.config/mmp/ +├── credentials.json.age # encrypted vault +├── age-key.txt # private key (chmod 600) +├── providers/ # user-installed providers (this directory) +└── settings.toml # user preferences +``` + +## Adding credentials + +```bash +mmp setup [--account ] +``` + +You'll be prompted for each `required_credentials` key declared in that +provider's `provider.yaml`. Secrets are read via `getpass` (no echo). + +To list what's stored: + +```bash +mmp list accounts +# wechat-article:default +# x-article:lewis +``` + +To delete an account: + +```bash +mmp setup --account +# (re-enter empty values to clear; future: explicit `mmp accounts rm`) +``` + +## Per-conversation override (ENV) + +Any environment variable matching a `required_credentials.key` overrides the +vault for that one run: + +```bash +WECHAT_APP_ID=wx... WECHAT_APP_SECRET=... mmp publish manifest.yaml +``` + +This is the recommended path for CI and one-off testing. + +## Multiple accounts + +A provider can have many accounts. Use `--account ` at setup, and +reference the account in the manifest: + +```yaml +targets: + - target: x-article + mode: draft + account: lewis +``` + +## Rotating + +Compromised vault key: + +```bash +rm ~/.config/mmp/credentials.json.age ~/.config/mmp/age-key.txt +mmp setup # re-enter all values +``` + +The new key is auto-generated on first `setup` after deletion. + +## What never leaves the vault + +- `result.json` (per-run summary) +- `publish-log.md` (per-run timeline) +- console output beyond `(received)` confirmations +- any git-tracked file in this repo + +If you find a credential leaked into one of these, report as a P0 bug. + +## Future (v0.3) + +- macOS Keychain backend +- Linux secret-service backend +- Windows Credential Manager backend + +The `Backend` ABC is already in place; switching is config-only once +implemented. +``` + +- [ ] **Step 2: Commit** + +```bash +git add docs/credentials.md +git commit -m "docs: add credentials & vault user guide" +``` + +--- + +## Task 5: `docs/manual-verification.md` + +**Files:** +- Create: `docs/manual-verification.md` + +- [ ] **Step 1: Write manual-verification checklist** + +```markdown +# Manual Verification Checklist + +Real account testing is **not** in CI. Run these locally before tagging a +release. + +## wechat-article + +Prerequisites: +- WeChat Official Account with API access enabled +- IP whitelist includes your test machine +- AppID + AppSecret available + +Steps: + +```bash +mmp setup wechat-article +# Enter WECHAT_APP_ID and WECHAT_APP_SECRET when prompted +mmp doctor +# Expect: providers >= 5; accounts: wechat-article:default +mmp publish examples/longform.yaml --mode-override dry-run +# Expect: RUN_DIR ; result.json status=ok mode_actual=dry-run +mmp publish examples/longform.yaml --mode-override draft +# Expect: status=ok, mode_actual=draft-platform, external_id is a draft media_id +# Verify: log into mp.weixin.qq.com → 草稿箱 → see the new draft +``` + +Cleanup: delete the draft from the WeChat console. + +## xiaohongshu + +Prerequisites: +- xiaohongshu skill installed locally with `xhs-login` cookie captured +- `XHS_COOKIE_PATH` set in vault to that cookie file + +```bash +mmp setup xiaohongshu +mmp publish examples/image-post.yaml --mode-override dry-run +mmp publish examples/image-post.yaml --mode-override draft +# Expect: result.json mode_actual=draft-local +# Verify: file exists and contains the payload +``` + +## wechat-image + +```bash +mmp publish examples/image-post.yaml --mode-override draft +# Expect: /packs/wechat-image/browser-flow.md exists +# Verify: open the guide manually, confirm steps are accurate +``` + +## x-article + +```bash +mmp publish examples/longform.yaml --mode-override draft +# Expect: result.json connector_status=not-implemented +# Manually follow the TODO-connector.md to create a draft +# Verify: x.com/i/articles → drafts shows the new entry +``` + +## substack + +Same pattern as x-article. + +## Cross-host check + +Verify the same vault works from both hosts: + +```bash +# In Claude Code: +python3 scripts/mmp.py list accounts +# In OpenClaw: +python3 scripts/mmp.py list accounts +# Both should show identical accounts. +``` + +## Sign-off + +Tag a release only when: +- [ ] wechat-article real-draft round-trip green +- [ ] xiaohongshu local-draft round-trip green +- [ ] wechat-image guide is accurate +- [ ] x-article + substack TODO docs accurate +- [ ] Cross-host vault read consistent +``` + +- [ ] **Step 2: Commit** + +```bash +git add docs/manual-verification.md +git commit -m "docs: add manual-verification checklist" +``` + +--- + +## Task 6: Archive Legacy `references/*.md` + +**Files:** +- Move: `references/candidate-skills.md` → `docs/legacy-research.md` +- Delete: `references/image-post-mvp.md`, `references/phase2-audit.md`, `references/workflows.md`, `references/manifest-schema.md`, `references/platform-map.md` +- Delete: `references/` (if empty) + +- [ ] **Step 1: Promote candidate-skills as legacy research note** + +```bash +git mv references/candidate-skills.md docs/legacy-research.md +``` + +Prepend a header to `docs/legacy-research.md`: + +```markdown +> Archived in v0.2. This file is the original ClawHub skill survey from v0.1 +> planning. Kept for historical context; do not edit. + +``` + +- [ ] **Step 2: Delete obsolete reference files** + +```bash +git rm references/image-post-mvp.md references/phase2-audit.md \ + references/workflows.md references/manifest-schema.md \ + references/platform-map.md +rmdir references 2>/dev/null || true +``` + +(`rmdir` is best-effort; if other files remain, leave the directory.) + +- [ ] **Step 3: Commit** + +```bash +git add docs/legacy-research.md +git diff --cached --stat +git commit -m "docs: archive legacy references; drop superseded files" +``` + +--- + +## Task 7: `.claude-plugin/plugin.json` + +**Files:** +- Create: `.claude-plugin/plugin.json` + +- [ ] **Step 1: Create directory + manifest** + +```bash +mkdir -p .claude-plugin +``` + +`.claude-plugin/plugin.json`: + +```json +{ + "name": "multi-media-publisher", + "version": "0.2.0", + "description": "Cross-platform content publishing orchestration: 小红书 / 微信图文 / 微信公众号文章 / X Articles / Substack. Wizard-driven manifest creation, draft-first safety, encrypted credential vault.", + "skills": ["./SKILL.md"], + "scripts": { + "publish": "scripts/mmp.py publish", + "validate": "scripts/mmp.py validate", + "setup": "scripts/mmp.py setup", + "list": "scripts/mmp.py list", + "resume": "scripts/mmp.py resume", + "doctor": "scripts/mmp.py doctor", + "wizard": "scripts/mmp.py wizard" + }, + "homepage": "https://github.com/yxliao-lewis/multi-media-publisher", + "license": "MIT", + "keywords": [ + "publishing", + "social-media", + "xiaohongshu", + "wechat", + "x", + "substack", + "claude-code", + "openclaw" + ] +} +``` + +> **Note:** Update `homepage` if the GitHub repo name/owner differs. + +- [ ] **Step 2: Commit** + +```bash +git add .claude-plugin/plugin.json +git commit -m "feat: add Claude Code plugin manifest" +``` + +--- + +## Task 8: Final SKILL.md Polish + +**Files:** +- Modify: `SKILL.md` + +- [ ] **Step 1: Update version + cross-link to plugin.json** + +Edit `SKILL.md` frontmatter: + +```yaml +--- +name: Multi-media Publisher +description: This skill should be used when the user asks to "多媒体发布", "多平台发布", "同步发布", "cross-post", "publish to multiple", "发到小红书和微信", "发布长文章到公众号/X/Substack", "新发布", "wizard", or wants one content package adapted and published/drafted across Xiaohongshu, WeChat image posts, WeChat Official Account articles, X Articles, Substack, or future video platforms. +version: 0.2.0 +--- +``` + +In the body, replace any reference to `Plan N` and replace the providers table with a stable v0.2 table (no Plan-X TODO callouts): + +```markdown +## v0.2 supported providers + +| Provider | Media | Mode support | Notes | +|---|---|---|---| +| `wechat-article` | longform | dry-run, draft | Real WeChat OA API; needs AppID/AppSecret | +| `xiaohongshu` | image-post (video planned) | dry-run, draft (local) | Uses xiaohongshu skill's `draft.sh` | +| `wechat-image` | image-post | dry-run, draft (browser-flow guide) | UI calibration TODO; guide-only path | +| `x-article` | longform | dry-run, draft (payload + TODO) | No connector yet; manual paste step | +| `substack` | longform | dry-run, draft (payload + TODO) | No connector yet; manual paste step | +``` + +Remove the "Plan 2", "Plan 3", "Plan 4" TODO markers. + +- [ ] **Step 2: Commit** + +```bash +git add SKILL.md +git commit -m "docs(skill): final v0.2.0 polish; remove plan-N markers" +``` + +--- + +## Task 9: README.md Rewrite + +**Files:** +- Modify: `README.md` + +- [ ] **Step 1: Replace `README.md`** + +```markdown +# multi-media-publisher + +Publish one piece of content to many platforms — 小红书, 微信图文, 微信公众号 +文章, X Articles, Substack — through one manifest, with draft-first safety +and an encrypted credential vault. + +Works as a Claude Code plugin **and** an OpenClaw skill from the same source. + +## Status + +v0.2 — provider abstraction + 5 first-party providers + conversational +manifest wizard + age-encrypted vault. See [`docs/HANDOFF.md`](docs/HANDOFF.md). + +## Install + +### As a Claude Code plugin + +``` +/plugin install multi-media-publisher +``` + +(Or clone this repo and point Claude Code at the directory.) + +### As an OpenClaw skill + +Clone into your skills directory: + +```bash +git clone https://github.com/yxliao-lewis/multi-media-publisher.git \ + ~/.openclaw/skills/multi-media-publisher +``` + +### Python deps + +```bash +pip install -e ".[dev]" +``` + +Requires Python 3.10+. + +## Quickstart + +### Conversational wizard (recommended) + +In Claude Code or OpenClaw, just say what you want: + +> 帮我把这篇文章发到公众号、X 长文章、Substack 草稿。 + +Claude reads `core/wizard/*.md` and walks you through source extraction → +target selection → manifest assembly → draft. + +### CLI + +```bash +# Validate a manifest +mmp validate examples/longform.yaml + +# Configure credentials for a provider +mmp setup wechat-article + +# Run a publish (defaults to draft mode in the manifest) +mmp publish examples/longform.yaml + +# List providers / accounts / runs +mmp list providers +mmp list accounts +mmp list runs + +# Self-check +mmp doctor +``` + +## Safety + +- Default `mode: draft`. Public publishing requires explicit `mode: publish` + in the manifest **plus** an in-conversation confirmation. +- Credentials are stored in `~/.config/mmp/credentials.json.age` (age-encrypted). +- Secrets never appear in `result.json`, `publish-log.md`, or printed output. +- Full policy: [`docs/safety-policy.md`](docs/safety-policy.md). + +## Architecture + +- [`docs/architecture.md`](docs/architecture.md) — high-level overview +- [`docs/provider-contract.md`](docs/provider-contract.md) — write your own provider +- [`docs/credentials.md`](docs/credentials.md) — vault and ENV usage +- [`docs/manual-verification.md`](docs/manual-verification.md) — pre-release checklist +- [`docs/superpowers/specs/`](docs/superpowers/specs/) — full design spec + +## Project layout + +``` +core/ # host-agnostic Python (manifest, providers, vault, runs) +providers/ # bundled first-party providers + wechat_article/ + xiaohongshu/ + wechat_image/ + x_article/ + substack/ +scripts/mmp.py # CLI entry +.claude-plugin/ # Claude Code plugin manifest +SKILL.md # OpenClaw + Claude Code skill manifest +docs/ # user-facing docs +tests/ # core tests + integration tests +``` + +## Contributing + +- New provider? Read [`docs/provider-contract.md`](docs/provider-contract.md). +- Bug or design discussion? Open an issue. + +## License + +MIT. +``` + +- [ ] **Step 2: Commit** + +```bash +git add README.md +git commit -m "docs: rewrite README for v0.2 install + usage" +``` + +--- + +## Task 10: `pyproject.toml` Version Bump + CHANGELOG + +**Files:** +- Modify: `pyproject.toml` +- Create: `CHANGELOG.md` + +- [ ] **Step 1: Bump version** + +In `pyproject.toml`: + +```toml +[project] +name = "multi-media-publisher" +version = "0.2.0" +``` + +(Already 0.2.0 from Plan 1; double-check.) + +- [ ] **Step 2: Create `CHANGELOG.md`** + +```markdown +# Changelog + +## 0.2.0 — 2026-05-XX + +Major refactor: provider abstraction + dual-host distribution + wizard. + +### Added + +- `core/` host-agnostic Python: manifest schema, provider registry, + credential vault (age-encrypted), run lifecycle, platform rules, settings, + wizard fragments +- 5 bundled providers: `wechat-article`, `xiaohongshu`, `wechat-image`, + `x-article`, `substack` +- `scripts/mmp.py` unified CLI: `validate`, `publish`, `setup`, `list`, + `resume`, `doctor`, `wizard` +- `.claude-plugin/plugin.json` for Claude Code plugin marketplace +- 3-stage conversational wizard (source extraction → target selection → + manifest assembly) + credential setup wizard +- Manifest schema v0.2: `schema_version`, full-form/short-form targets, + `defaults` block, `dry-run` mode, account override per target +- Age-encrypted vault at `~/.config/mmp/credentials.json.age` +- GitHub Actions CI matrix (macOS + ubuntu × Python 3.10/3.11/3.12) +- User-facing docs: `architecture.md`, `provider-contract.md`, + `credentials.md`, `safety-policy.md`, `manual-verification.md` + +### Changed + +- Default `mode` is `draft`; `publish` requires explicit confirmation +- Manifest fields normalized; lock-file `manifest.lock.json` written per run +- `runs/-/` is now self-contained: manifest, lock, packs, result, + log, checkpoints, artifacts + +### Deprecated + +- `scripts/prepare_image_post.py`, `scripts/prepare_longform.py`, + `scripts/execute_image_post.py`, `scripts/adapt_content.py`, + `scripts/publish_manifest.py`, `scripts/wechat_api_draft.py` — kept as + thin shims; removal in v0.3 + +### Removed + +- Legacy `references/*.md` (moved to `docs/` or `providers//notes.md`) + +## 0.1.0 — 2026-05 (pre-redesign) + +- Initial OpenClaw skill: prepare/execute scripts, image-post + longform + pipelines, WeChat API dry-run helper, local smoke test. +``` + +- [ ] **Step 3: Commit** + +```bash +git add pyproject.toml CHANGELOG.md +git commit -m "chore: bump 0.2.0 + CHANGELOG" +``` + +--- + +## Task 11: GitHub Actions CI + +**Files:** +- Create: `.github/workflows/ci.yml` + +- [ ] **Step 1: Create CI workflow** + +```bash +mkdir -p .github/workflows +``` + +`.github/workflows/ci.yml`: + +```yaml +name: CI + +on: + push: + branches: [main] + pull_request: + branches: [main] + +jobs: + test: + name: ${{ matrix.os }} / py${{ matrix.python-version }} + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, macos-latest] + python-version: ["3.10", "3.11", "3.12"] + + steps: + - uses: actions/checkout@v4 + + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + cache: pip + cache-dependency-path: pyproject.toml + + - name: Install + run: | + python -m pip install --upgrade pip + pip install -e ".[dev]" + + - name: Lint (ruff) + run: | + python -m ruff check . + python -m ruff format --check . + + - name: Typecheck (mypy) + run: python -m mypy core providers + + - name: Unit tests + run: python -m pytest -q + + - name: Smoke test + env: + # Provide ephemeral, fake home so smoke writes its vault into a + # job-local dir (the smoke script also overrides XDG_CONFIG_HOME). + HOME: ${{ runner.temp }} + run: python scripts/test_local.py +``` + +- [ ] **Step 2: Verify YAML parses** + +Run: `python3 -c "import yaml; yaml.safe_load(open('.github/workflows/ci.yml'))"` +Expected: no error. + +- [ ] **Step 3: Commit** + +```bash +git add .github/workflows/ci.yml +git commit -m "ci: GitHub Actions matrix (ubuntu + macos × py3.10/3.11/3.12)" +``` + +--- + +## Task 12: Update `docs/HANDOFF.md` for v0.2 Completion + +**Files:** +- Modify: `docs/HANDOFF.md` + +- [ ] **Step 1: Append final status** + +```markdown + +### Plan 4 status (this commit range) + +- `.claude-plugin/plugin.json` published — Claude Code plugin marketplace ready +- `SKILL.md` v0.2.0 final — dual-host description, no plan-N markers +- `README.md` rewritten as install + quickstart +- New docs: `architecture.md`, `provider-contract.md`, `credentials.md`, + `safety-policy.md`, `manual-verification.md` +- `references/` archived; `legacy-research.md` retained for context +- `.github/workflows/ci.yml` — matrix CI (ubuntu+macos × py3.10/3.11/3.12) +- `CHANGELOG.md` — v0.2.0 release notes +- `pyproject.toml` — version 0.2.0 + +### v0.2 → v0.3 hand-off + +The next milestone: + +1. **Real-account verification** — work the `manual-verification.md` checklist + for wechat-article, xiaohongshu, wechat-image +2. **x-article / substack connectors** — replace TODO-connector.md with real + draft-creation logic (likely browser automation) +3. **Remove deprecation shims** — drop `scripts/prepare_*`, `scripts/execute_*`, + `scripts/adapt_content.py`, `scripts/publish_manifest.py`, `scripts/wechat_api_draft.py` +4. **Keychain credential backend** — implement `KeychainBackend`; expose via + `settings.toml.credentials.backend` +5. **Resume command** — implement `mmp resume ` checkpoint replay +6. **Video-post providers** — `xiaohongshu_video`, `wechat_channel`, `douyin`, + `bilibili`, `youtube_shorts` +7. **User-folder provider auto-trust + signing** — In v0.2 the + `ProviderRegistry.discover()` defaults to `trust_user=False`, so providers + in `~/.config/mmp/providers/` are detected (visible via `mmp list providers` + would show them only if discovery is invoked with trust_user=True manually + in Python) but not loaded by the CLI. v0.3 will: + - Read `settings.toml.providers.trusted_user_providers` + - Prompt the user on first-encounter of an untrusted user provider + - Add chosen provider to the trusted list + - Add optional signature verification (independent of trust prompt) + +### v0.2 ship checklist + +- [ ] All 4 plans' tasks completed and committed +- [ ] CI green on all matrix jobs +- [ ] `mmp doctor` clean on a fresh machine after `pip install -e .` +- [ ] Real-account verification (see `docs/manual-verification.md`) green +- [ ] Tag `v0.2.0` +- [ ] Submit to Claude Code plugin marketplace +``` + +- [ ] **Step 2: Commit** + +```bash +git add docs/HANDOFF.md +git commit -m "docs(handoff): note Plan 4 completion + v0.3 roadmap" +``` + +--- + +## Task 13: Final Lint + Test + Tag + +- [ ] **Step 1: Run full quality gate** + +```bash +make test +``` + +Expected: lint + typecheck + unit + smoke all green. + +- [ ] **Step 2: Verify CI YAML parses with GitHub's tooling (optional)** + +If `act` is installed: + +```bash +act -l +``` + +Expected: lists the `test` job. Skip if `act` is unavailable. + +- [ ] **Step 3: Verify package install from clean env** + +```bash +python3 -m venv /tmp/mmp-clean +/tmp/mmp-clean/bin/pip install -e ".[dev]" +/tmp/mmp-clean/bin/python -c "from core import manifest, provider, credentials, run; print('ok')" +``` + +Expected: prints `ok`. + +- [ ] **Step 4: Cleanup any straggling diffs** + +```bash +git status +git diff --cached --quiet || git commit -m "chore(plan-4): final cleanup" +``` + +- [ ] **Step 5: Tag (only if all checks above pass AND user is ready to release)** + +```bash +git tag v0.2.0 -m "v0.2.0 — provider abstraction + dual-host + wizard" +git push origin v0.2.0 +``` + +> **Skip Step 5 if** real-account verification (`docs/manual-verification.md`) +> hasn't been done yet. Tag only after green real-account dry-run+draft for at +> least `wechat-article` and `xiaohongshu`. + +--- + +## Self-Review Checklist + +- [ ] All 13 tasks committed +- [ ] `make test` green +- [ ] `.claude-plugin/plugin.json` exists with correct version +- [ ] `SKILL.md` no longer references "Plan N" +- [ ] `README.md` rewritten for install + quickstart +- [ ] `docs/architecture.md`, `docs/provider-contract.md`, `docs/credentials.md`, + `docs/safety-policy.md`, `docs/manual-verification.md` all present +- [ ] `references/` directory empty or contains only files we explicitly kept +- [ ] `.github/workflows/ci.yml` parses +- [ ] `CHANGELOG.md` v0.2.0 entry complete +- [ ] Spec sections covered: §9 (dual-host), §11 (CI), §13 risks acknowledged +- [ ] Spec items deferred to v0.3: keychain backend, resume, video-post, + provider signing, real X/Substack connectors + +## Hand-off + +v0.2 is feature-complete after this plan. The next steps live in +`docs/HANDOFF.md` under "v0.2 → v0.3 hand-off". The first action of v0.3 is +real-account verification per `docs/manual-verification.md`. diff --git a/docs/superpowers/specs/2026-05-05-multi-media-publisher-redesign-design.md b/docs/superpowers/specs/2026-05-05-multi-media-publisher-redesign-design.md new file mode 100644 index 0000000..1930a1e --- /dev/null +++ b/docs/superpowers/specs/2026-05-05-multi-media-publisher-redesign-design.md @@ -0,0 +1,787 @@ +# Multi-media Publisher v0.2 Redesign — Design Spec + +**Date**: 2026-05-05 +**Status**: Draft (awaiting user review) +**Owner**: Lewis (yxliao.lewis@gmail.com) +**Supersedes**: existing v0.1 SKILL.md scripts-first architecture + +--- + +## 1. Context + +`multi-media-publisher` 当前是一个 OpenClaw skill,处于"本地 MVP 跑绿"状态: +- `image-post`(小红书 + 微信图文)prepare + 小红书本地 draft + 微信 browser flow guide +- `longform`(公众号 + X Articles + Substack)prepare 输出 payload + preview +- 微信公众号 API helper(dry-run only) +- 默认 draft mode;公开发布要求二次确认;本地 smoke test 覆盖语法和 prepare 链路 + +存在的工程问题: +- Provider 逻辑硬编码在 `prepare_image_post.py` / `prepare_longform.py` / `execute_image_post.py`,加新平台要改 6 处 +- 没有真实 connector 接通(所有 target 都停在 payload / dry-run) +- Manifest 必须用户手写 YAML,门槛高 +- 凭证散在 ENV / `~/.openclaw/` 各处,不可移植 +- 仅在 OpenClaw 工作,Claude Code 无法触发 + +## 2. Goals & Non-Goals + +### Goals + +1. **架构抽象**:把"调度层"和"provider 实现"彻底解耦;新平台 = 新一个 provider 包,零侵入 +2. **双宿主分发**:同一份代码同时是 Claude Code plugin 和 OpenClaw skill +3. **对话式 manifest**:用户不再手写 YAML,Claude 通过三阶段 wizard 引导 +4. **统一凭证仓库**:跨宿主共享、加密、按 provider × account 隔离 +5. **Plugin marketplace ready**:第三方能写 provider folder-drop 即生效;first-party 走同一接口 +6. **Run lifecycle 可观测、可重入**:失败可恢复、log 可读、result.json machine-readable +7. **Platform rules as code**:标题长度、图片比例、字数等做成 lint,violation 在 prepare 阶段拦下 + +### Non-Goals (v0.2) + +- 不上 PyPI(保留代码组织上的可拆性,但不发包) +- 不做 video-post(仅预留接口) +- 不做 schedule(仅记录字段) +- 不做 GUI / TUI(wizard 完全 Claude 对话驱动) +- 不真账号 e2e CI(仅 mock connector) +- 不重做 OpenClaw browser policy 兼容(继续作为 fallback,主路径走 API) + +## 3. Architecture + +### 3.1 三层定位 + +| 层 | 内容 | 约束 | +|---|---|---| +| **Shell** | `SKILL.md` + `.claude-plugin/plugin.json` | 薄壳;只声明触发 + 入口 + 版本;不放业务逻辑 | +| **Core** | `core/` Python 模块 | 宿主无关;不 import providers;不读宿主特定路径;未来可拆 PyPI | +| **Providers** | `providers//`(bundled)+ `~/.config/mmp/providers//`(user) | 同一 `Provider` 接口;互不 import | + +### 3.2 项目目录结构 + +``` +multi-media-publisher/ +├── .claude-plugin/ +│ └── plugin.json # CC plugin manifest +├── SKILL.md # 双宿主触发 +├── README.md +├── docs/ +│ ├── architecture.md +│ ├── provider-contract.md +│ ├── credentials.md +│ ├── HANDOFF.md +│ └── superpowers/specs/ # 本文件所在目录 +├── core/ +│ ├── __init__.py +│ ├── manifest.py # schema + 校验 + lock +│ ├── provider.py # Provider base + ProviderRegistry +│ ├── credentials.py # CredentialStore + backends +│ ├── wizard/ +│ │ ├── source_extraction.md +│ │ ├── target_selection.md +│ │ ├── manifest_assembly.md +│ │ └── credential_setup.md +│ ├── run.py # run lifecycle + checkpoint + result +│ ├── rules.py # PlatformRules base + Violation 类型 +│ ├── host.py # 宿主探测(仅用于 user-data 路径解析) +│ └── errors.py +├── providers/ # bundled +│ ├── xiaohongshu/ +│ │ ├── provider.yaml +│ │ ├── provider.py +│ │ ├── rules.py +│ │ └── tests/ +│ ├── wechat_image/ +│ ├── wechat_article/ +│ ├── x_article/ +│ └── substack/ +├── examples/ +│ ├── image-post.yaml +│ └── longform.yaml +├── runs/ # gitignored;运行时输出 +├── scripts/ +│ ├── mmp.py # 统一 CLI 入口 +│ └── test_local.py +├── tests/ +│ ├── core/ +│ └── integration/ +├── Makefile +└── pyproject.toml # 仅用于本地 dev(lint / type / 路径) +``` + +### 3.3 共享用户数据路径 + +跨 CC / OpenClaw 共享,遵循 XDG: + +``` +~/.config/mmp/ +├── credentials.json.age # age 加密 vault +├── age-key.txt # vault 密钥(chmod 600) +├── providers/ # user-installed providers +│ └── / +│ ├── provider.yaml +│ └── provider.py +├── settings.toml # 偏好(默认 mode、默认目标列表、wizard 开关) +└── cookies/ # 平台 cookie 文件目录 +``` + +宿主探测(`core/host.py`)只用于: +- 决定 `runs/` 写在哪(默认 skill 目录下;ENV `MMP_RUNS_DIR` 覆盖) +- 决定 user-data 路径(XDG_CONFIG_HOME 优先,否则 `~/.config/mmp/`) + +### 3.4 数据流(一次发布的生命周期) + +``` +用户在 CC / OpenClaw 触发 + → SKILL.md 引导 + → core/wizard 三阶段对话(source → targets → manifest 确认) + → 落盘 runs/-/manifest.yaml + → core/run.dispatch(manifest): + for target in manifest.targets: + provider = ProviderRegistry.resolve(target) + provider.validate(manifest) # platform_rules.lint() + provider.prepare(manifest, run_dir) # 写 packs// + if mode in {draft, publish}: + credentials = CredentialStore.get(target.name, target.account) + provider.execute(run_dir, mode, credentials) + 写 result.json + 追加 publish-log.md + checkpoint + → 摘要回报 +``` + +### 3.5 关键架构约束 + +1. `core/` 不能 `import providers.*`、不能 `from skill_root import *` +2. Provider 之间不能互相 import +3. Vault 读写只通过 `core.credentials.CredentialStore` +4. 任何"调宿主特定路径"必须封在 provider 内部,不漏到 core +5. CC 和 OpenClaw 入口都收敛到 `scripts/mmp.py` +6. `runs/-/` 是 self-contained,不依赖项目外文件即可恢复运行 + +## 4. Provider Contract + +### 4.1 Provider 抽象类 + +```python +# core/provider.py + +class Provider(ABC): + name: str # "xiaohongshu" + display_name: str # "小红书" + media_types: list[str] # ["image-post", "video-post"] + capabilities: dict[str, bool] # {"draft": True, "publish": False, "schedule": False} + required_credentials: list[CredentialSpec] + platform_rules: PlatformRules + + @abstractmethod + def validate(self, manifest: Manifest, target: Target) -> ValidationResult: ... + + @abstractmethod + def prepare(self, manifest: Manifest, target: Target, run_dir: Path) -> PreparedPayload: ... + + @abstractmethod + def execute( + self, + run_dir: Path, + target: Target, + mode: Mode, + credentials: dict[str, str], + ) -> ExecutionResult: ... + + def health_check(self, credentials: dict[str, str]) -> HealthStatus: + """Optional: 连通性测试。default 返回 unknown。""" + return HealthStatus.unknown +``` + +### 4.2 provider.yaml(元信息) + +```yaml +name: xiaohongshu +display_name: 小红书 +media_types: + - image-post + - video-post +capabilities: + draft: true + publish: false # MVP 不开 + schedule: false +required_credentials: + - key: XHS_COOKIE_PATH + description: "Path to Xiaohongshu cookies file" + secret: false + setup_hint: "运行 `xhs-login` 抓 cookies;或手动从浏览器导出" +entry: provider:XiaohongshuProvider +schema_version: 1 +``` + +### 4.3 PlatformRules + +```python +# core/rules.py + +@dataclass +class PlatformRules: + title_max: int | None = None + body_max: int | None = None + image_count_min: int | None = None + image_count_max: int | None = None + image_aspect_ratios: list[str] | None = None # e.g. ["3:4", "1:1"] + tag_max: int | None = None + cover_required: bool = False + cover_aspect_ratios: list[str] | None = None + extra_lints: list[Callable[[Manifest, Target], list[Violation]]] = field(default_factory=list) + + def lint(self, manifest: Manifest, target: Target) -> list[Violation]: ... +``` + +`Violation` 分级:`error`(阻断 prepare)/ `warning`(仅提示)/ `info`。 + +### 4.4 命名约定 + +| 用途 | 形式 | 例 | +|---|---|---| +| 目录名(Python module) | `snake_case` | `providers/wechat_article/` | +| `provider.yaml.name`(manifest 中引用) | `kebab-case` | `wechat-article` | +| Pack 目录(`packs//`) | 同 `provider.yaml.name` | `packs/wechat-article/` | +| Credential account ID | `:` | `wechat-article:lewis` | + +`ProviderRegistry` 按 `provider.yaml.name`(kebab)建索引;目录名只用于 Python import 路径。两者一一对应但不必同形(hyphen 在 Python 模块名中非法)。 + +### 4.5 Provider 发现与加载 + +```python +class ProviderRegistry: + def discover(self) -> None: + # 1. bundled: scan providers/*/provider.yaml + # 2. user: scan ~/.config/mmp/providers/*/provider.yaml + # 3. 同名时 user 覆盖 bundled,但首次加载 user provider 提示用户确认 + ... + + def resolve(self, target_name: str) -> Provider: ... + def list(self) -> list[ProviderInfo]: ... +``` + +User-installed provider 首次加载时 SKILL.md 提示:「即将加载第三方 provider ``(来自 `~/.config/mmp/providers//`),确认?」用户同意后写入 `~/.config/mmp/settings.toml` 的 trusted_providers 列表。 + +## 5. Manifest Schema (v0.2) + +### 5.1 Schema 顶层 + +```yaml +schema_version: "0.2" # 必填,用于未来兼容性 +type: image-post | longform | video-post +title: "Human-readable title" +body: "Inline content or ./path/to/content.md" +summary: "Optional short synopsis" +mode: draft # draft | publish | dry-run +language: zh-CN +defaults: # 可选;应用到所有 target + account: default + options: {} +targets: + # 简写:string,沿用顶层 mode + - xiaohongshu + # 全写:object,可覆盖 mode / account / options + - target: wechat-article + mode: draft + account: lewis + options: + digest: "可选摘要" + cover_url: "https://..." +assets: + cover: ./cover.png + images: [] + video: null +tags: [] +cta: "Optional call to action" +metadata: + slug: optional-slug + source: optional-source + # run_id 由 system 写,不要手填 +``` + +### 5.2 Mode 语义 + +| Mode | 行为 | +|---|---| +| `dry-run` | 仅 prepare,不调 connector,不写凭证 | +| `draft` | prepare + execute 到平台 draft(或 local draft if 平台不支持) | +| `publish` | prepare + execute 到公开发布;额外二次确认 | + +### 5.3 校验 (`core.manifest.validate`) + +执行顺序: +1. Schema 校验:必填字段、类型、enum 值 +2. Target 解析:每个 target 在 ProviderRegistry 中存在 +3. Media-type 兼容:`type` ∈ `provider.media_types` +4. Capability 校验:`mode` ∈ provider.capabilities 中为 true 的项 +5. PlatformRules.lint() 跑每个 target;error 级阻断 +6. Credential 完备性:required_credentials 都已在 vault 中(缺则触发 setup wizard) + +校验通过后落 `manifest.lock.json`(归一化形式:所有简写展开、defaults 合并)。 + +## 6. Credential Vault + +### 6.1 文件布局 + +``` +~/.config/mmp/ +├── credentials.json.age # 加密文件 +└── age-key.txt # 密钥(chmod 600;ENV MMP_VAULT_KEY 覆盖) +``` + +加密方案:`age` (X25519,Go/Rust/Python 均有实现;选 `pyrage` 或 shell out 到 `age` CLI)。 + +### 6.2 解密后的 JSON 格式 + +```json +{ + "version": 1, + "accounts": { + "wechat-article:default": { + "WECHAT_APP_ID": "wx...", + "WECHAT_APP_SECRET": "..." + }, + "xiaohongshu:default": { + "XHS_COOKIE_PATH": "~/.config/mmp/cookies/xhs.json" + }, + "x-article:lewis": { + "X_AUTH_TOKEN": "..." + }, + "substack:default": { + "SUBSTACK_SESSION_COOKIE": "..." + } + }, + "metadata": { + "created_at": "2026-05-05T12:00:00Z", + "last_modified": "2026-05-05T12:00:00Z" + } +} +``` + +Account ID 形式:`:`,default account 名为 `default`。manifest 里 target 可指定 `account: lewis` 选用其他账号。 + +### 6.3 CredentialStore API + +```python +# core/credentials.py + +class CredentialStore: + def __init__(self, backend: Backend = FileBackend()): ... + + def get(self, provider: str, account: str = "default") -> dict[str, str]: + """优先级:ENV > vault > MissingCredentialError""" + + def set(self, provider: str, account: str, values: dict[str, str]) -> None: ... + + def list_accounts(self, provider: str | None = None) -> list[str]: ... + + def delete(self, provider: str, account: str) -> None: ... + + def health_check(self, provider: str, account: str = "default") -> HealthStatus: + """调 provider.health_check 验证凭证可用""" +``` + +Backends: +- `FileBackend`:默认,age 加密 +- `EnvBackend`:从 ENV 读,仅供 CI +- `KeychainBackend`:未来扩展(macOS / linux secret-service / win credential manager) + +### 6.4 Setup Wizard 触发 + +3 种入口: +1. 用户主动:"setup credentials" / "添加微信账号" / "configure xiaohongshu" +2. Manifest 校验缺凭证 → 反向触发该 provider 的 setup +3. CLI:`mmp setup [provider]` + +Wizard 行为(`core/wizard/credential_setup.md`): +- 询问 provider × account +- 列出 required_credentials;逐项询问;secret 字段不回显 +- 写入 vault 后调 `health_check`,结果回报 +- 失败时给出 `setup_hint` + +### 6.5 安全约束 + +- vault 文件 chmod 600 +- age-key.txt chmod 600 +- 凭证值绝不进 result.json / publish-log.md / 任何 git-tracked 文件 +- provider 拿到 `credentials: dict[str, str]` 后,禁止打印;如必须 log,要 mask +- ENV 覆盖优先,便于 CI / 临时使用,不污染 vault + +## 7. Wizard Flow + +3 阶段对话流,prompt 片段在 `core/wizard/*.md`,被 SKILL.md 引用。 + +### 7.1 阶段 1 — Source Extraction (`source_extraction.md`) + +**输入**:用户贴的 markdown / 截图描述 / 链接 / 自然语言。 + +**Claude 行为**: +- 识别媒介类型(image-post / longform / video-post) +- 抽取候选字段:title / body / cover / images / tags / cta +- 不存在的字段反问,但**最小化反问**(一次最多 2 个字段) + +**输出**:内部草稿(不直接落盘)。 + +### 7.2 阶段 2 — Target Selection (`target_selection.md`) + +**输入**:阶段 1 草稿 + `ProviderRegistry.list()` + `CredentialStore.list_accounts()`。 + +**Claude 行为**: +- 列可用 targets(按 `media_types` 过滤) +- 标 credentials 状态:✓ 已配置 / ✗ 缺凭证(提示 setup) / ! health_check 失败 +- 询问 mode(draft / publish / dry-run) +- 询问平台特化:要不要给小红书写 hook、给 X Article 起独立标题、给微信加摘要…… + +**输出**:target 列表 + per-target options。 + +### 7.3 阶段 3 — Manifest Assembly (`manifest_assembly.md`) + +**输入**:阶段 1 + 阶段 2。 + +**Claude 行为**: +- 渲染 manifest yaml +- 跑 `manifest.validate()`,展示 violations +- error 级 violation 必须用户解决(提供修复建议) +- warning 级用户可选择忽略 +- 给最终预览 + 一键确认 +- 落盘 `runs/-/manifest.yaml` 和 `manifest.lock.json` + +**输出**:可执行 manifest 路径 + run_id。 + +### 7.4 入口 + +- **对话**:SKILL.md 触发关键词("发一组到..." / "cross-post to..." / "publish to..." / "新发布")→ Claude 调 `core.wizard.run()` +- **CLI 跳过**:`mmp publish ./manifest.yaml` 直接执行(适合已有 manifest) +- **半自动**:`mmp wizard --type longform --targets wechat-article,x-article` 进 wizard 但跳过 target 选择 + +### 7.5 Wizard 关闭开关 + +`~/.config/mmp/settings.toml`: +```toml +[wizard] +enabled = true +auto_save_manifest = true +default_mode = "draft" +``` + +## 8. Run Lifecycle & Logging + +### 8.1 Run dir 布局 + +``` +runs/-/ +├── manifest.yaml # 用户确认的源 +├── manifest.lock.json # 归一化、validated +├── packs/ +│ └── / +│ ├── payload.json +│ ├── content.md +│ ├── browser-flow.md # 仅当 provider 需要 +│ └── adapted.md # 平台特化后的内容 +├── result.json # 顶层结果 +├── publish-log.md # 人读 timeline +├── checkpoints/ +│ └── .checkpoint.json +└── artifacts/ + ├── -screenshot.png + └── -platform-response.json +``` + +### 8.2 result.json + +```json +{ + "run_id": "20260505-180000-ai-foo", + "schema_version": 1, + "manifest_path": "manifest.yaml", + "started_at": "2026-05-05T18:00:00Z", + "completed_at": "2026-05-05T18:01:32Z", + "mode": "draft", + "host": "claude-code", + "mmp_version": "0.2.0", + "targets": [ + { + "name": "xiaohongshu", + "account": "default", + "status": "ok", + "mode_actual": "draft-local", + "draft_url": null, + "external_id": "xhs_local_xxx", + "started_at": "2026-05-05T18:00:01Z", + "completed_at": "2026-05-05T18:00:15Z", + "error": null, + "checkpoint": "checkpoints/xiaohongshu.checkpoint.json", + "violations": [] + } + ] +} +``` + +`status` enum:`ok` / `failed` / `skipped` / `partial` / `pending`。 +`mode_actual` enum:`dry-run` / `draft-local` / `draft-platform` / `published`。 + +### 8.3 publish-log.md + +人读 timeline,每个事件一行: + +``` +2026-05-05T18:00:00Z RUN_START run_id=20260505-180000-ai-foo mode=draft +2026-05-05T18:00:01Z TARGET_START target=xiaohongshu account=default +2026-05-05T18:00:03Z PREPARE_OK target=xiaohongshu payload_path=packs/xiaohongshu/payload.json +2026-05-05T18:00:15Z EXECUTE_OK target=xiaohongshu mode_actual=draft-local +2026-05-05T18:00:15Z TARGET_DONE target=xiaohongshu status=ok +2026-05-05T18:01:32Z RUN_DONE overall=ok +``` + +### 8.4 Checkpoint & 重入 + +每个 target 在关键步骤写 checkpoint: + +```json +{ + "target": "wechat-article", + "step": "media_uploaded", + "started_at": "...", + "external_ids": {"thumb_media_id": "..."}, + "next_step": "create_draft" +} +``` + +`mmp resume [--target ]` 从最后 checkpoint 继续。 +重入语义:execute 必须 idempotent;同一 step 重跑要么是 no-op 要么 detect 已完成跳过。 + +### 8.5 错误模型 + +```python +# core/errors.py + +class MMPError(Exception): ... +class ManifestError(MMPError): ... +class ProviderNotFoundError(MMPError): ... +class MissingCredentialError(MMPError): ... +class PlatformRuleViolation(MMPError): ... +class ProviderExecutionError(MMPError): + target: str + step: str + upstream: Exception | None + retryable: bool +``` + +Provider 抛出 `ProviderExecutionError` 时声明 `retryable` 布尔;core 据此决定是否标记可 resume。 + +## 9. Dual-Host Distribution + +### 9.1 SKILL.md(双兼容) + +frontmatter 保持现有 yaml:`name` + `description` + `version`。 +描述里的触发关键词同时覆盖 OpenClaw 和 Claude Code 路由器。 + +正文中清晰说明: +- 入口:`scripts/mmp.py` +- Wizard 触发短语 +- 安全策略 + +### 9.2 `.claude-plugin/plugin.json` + +```json +{ + "name": "multi-media-publisher", + "version": "0.2.0", + "description": "Cross-platform content publishing orchestration: 小红书 / 微信图文 / 公众号文章 / X Articles / Substack.", + "skills": ["./SKILL.md"], + "scripts": { + "publish": "scripts/mmp.py publish", + "setup": "scripts/mmp.py setup", + "list": "scripts/mmp.py list" + }, + "homepage": "https://github.com//multi-media-publisher", + "license": "MIT" +} +``` + +### 9.3 OpenClaw 安装 + +继续放 `skills/multi-media-publisher/`,无变化。`scripts/mmp.py` 同时为 OpenClaw 入口。 + +### 9.4 入口收敛 + +CC 和 OpenClaw 都通过 SKILL.md → `scripts/mmp.py` → `core/`。 + +`mmp.py` 子命令: +- `mmp publish ` — 执行 +- `mmp wizard [--type ... --targets ...]` — 交互 +- `mmp setup [provider]` — 凭证 +- `mmp list [providers|accounts|runs]` — 检视 +- `mmp resume ` — 重入 +- `mmp validate ` — 仅校验 +- `mmp doctor` — 自检(vault / providers / health) + +### 9.5 发布渠道 + +- GitHub repo(source of truth) +- Claude Code plugin marketplace(按官方流程提交) +- 不上 PyPI(保持代码组织上的 pkg-ready,但不发包) + +## 10. Bundled Provider Migration + +5 个 first-party provider,按以下顺序迁移: + +### 10.1 迁移矩阵 + +| Provider | 旧位置 | 新位置 | 真实 connector 状态 | v0.2 目标 | +|---|---|---|---|---| +| wechat_article | scripts/wechat_api_draft.py + prepare_longform.py | providers/wechat_article/ | API draft dry-run | 真实账号验证草稿 | +| xiaohongshu | scripts/execute_image_post.py + prepare_image_post.py | providers/xiaohongshu/ | 本地 draft | 平台 draft 接通 | +| wechat_image | scripts/prepare_image_post.py | providers/wechat_image/ | browser flow guide | 仅 prepare + guide(unblocked 后再接 browser) | +| x_article | scripts/prepare_longform.py | providers/x_article/ | payload only | 接 `x-articles` connector OR browser stub | +| substack | scripts/prepare_longform.py | providers/substack/ | payload only | 接 `substack-autopilot` OR browser stub | + +### 10.2 单 provider 迁移步骤 + +每个 provider 迁移 = 6 个动作: +1. 写 `provider.yaml` +2. 写 `provider.py`(继承 `Provider`,实现 4 个抽象方法) +3. 写 `rules.py`(PlatformRules 实例 + extra_lints) +4. 写 `tests/`(prepare 输入/输出 fixture + dry-run execute 单测) +5. 老脚本里对应逻辑改成 thin wrapper(call 新 provider);smoke test 同时跑新老两路确认等价 +6. 老脚本标 deprecated;下个版本删除 + +### 10.3 deprecate 旧脚本时间表 + +- v0.2 发布:旧脚本仍在,调新 provider;`make test` 同时验证新老路径 +- v0.3:旧脚本删除;`make test` 仅走新路径 + +## 11. Testing & CI + +### 11.1 测试分层 + +| 层 | 位置 | 范围 | 跑法 | +|---|---|---|---| +| Core 单元 | `tests/core/` | manifest schema、credential store、provider registry、wizard prompt 渲染、rules.lint | pytest | +| Provider 单元 | `providers//tests/` | prepare / rules / dry-run execute | pytest(被 provider discovery 自动收集) | +| 集成 | `tests/integration/` | 完整 run lifecycle,end-to-end mock connector | pytest | +| Smoke | `scripts/test_local.py` | 现有 make test,扩展为每 provider 一组 | `make test` | + +### 11.2 CI matrix(GitHub Actions) + +- OS:macOS-latest + ubuntu-latest +- Python:3.10 / 3.11 / 3.12 +- Steps:lint (ruff + black --check) → typecheck (mypy core/) → pytest → smoke +- 不跑真账号;secrets 全 mock +- 在 PR 上必须全绿 + +### 11.3 真账号验证(手动) + +`docs/manual-verification.md` 列出每个 provider 的真账号验证步骤: +- 准备凭证 +- 跑 health_check +- 跑 dry-run +- 跑 draft +- 仅在用户确认后跑 publish + +## 12. Extension Hooks + +### 12.1 Video-post (v0.3+) + +- 新增 media type:在 manifest schema 里 `type: video-post` 已合法 +- `assets.video` 字段已存在 +- 新 providers// 即可:`xiaohongshu_video`、`wechat_channel`、`douyin`、`bilibili`、`youtube_shorts` +- v0.2 不实现,仅保证接口预留 + +### 12.2 Schedule + +- manifest 加 optional `schedule:` 字段(ISO 8601 timestamp) +- core 不长驻、不实际触发;仅在 manifest 中记录 +- 第三方可写 schedule provider 或外部 cron 读 manifest +- v0.2 仅记录,不执行 + +### 12.3 第三方 provider + +- folder-drop 至 `~/.config/mmp/providers//` +- ProviderRegistry 启动时扫描 +- 校验 provider.yaml schema +- 首次加载提示用户确认(防止恶意 provider) +- 信任后写入 `settings.toml.trusted_providers` + +## 13. Open Questions / Risks + +1. **age 加密 vs OS keychain**:v0.2 默认 age 文件方案;keychain 留给 v0.3。需确认用户对额外依赖(`age` CLI 或 `pyrage`)的容忍度。 +2. **微信公众号 API 真实账号**:仍未验证;v0.2 完成时必须有一次真实账号 dry-run + draft 通过。 +3. **小红书 platform draft**:现有 `xhs/save-platform-draft.sh` 是否能在 CC 环境下跑?需要审一遍依赖。 +4. **第三方 provider 安全**:folder-drop 是任意 Python 代码执行风险。trusted_providers 机制要不要加签名?v0.2 仅做 confirmation prompt,不签名。 +5. **Wizard prompt 的 i18n**:当前所有 wizard md 都是中文。英文用户怎么处理?v0.2 中文优先;英文翻译留给社区贡献。 +6. **OpenClaw 路径硬编码迁移**:当前代码里 `~/.openclaw/tmp/` 等路径需要扫一遍,全部走 `core.host`。 + +## 14. Out-of-Scope (v0.2) + +明确不做: +- PyPI 发包 +- Video-post 真实 connector +- Schedule 执行器 +- GUI / TUI / Web UI +- 自动 i18n +- 真账号 e2e CI +- Provider 数字签名 +- Multi-tenant / 团队协作 +- Analytics / 发布数据回流 + +--- + +## Appendix A: 文件树最终态预览 + +``` +multi-media-publisher/ +├── .claude-plugin/plugin.json +├── SKILL.md +├── README.md +├── Makefile +├── pyproject.toml +├── docs/ +│ ├── architecture.md +│ ├── provider-contract.md +│ ├── credentials.md +│ ├── manual-verification.md +│ ├── HANDOFF.md +│ └── superpowers/specs/2026-05-05-multi-media-publisher-redesign-design.md +├── core/ +│ ├── __init__.py +│ ├── manifest.py +│ ├── provider.py +│ ├── credentials.py +│ ├── wizard/{source_extraction,target_selection,manifest_assembly,credential_setup}.md +│ ├── run.py +│ ├── rules.py +│ ├── host.py +│ └── errors.py +├── providers/ +│ ├── xiaohongshu/{provider.yaml,provider.py,rules.py,tests/} +│ ├── wechat_image/{...} +│ ├── wechat_article/{...} +│ ├── x_article/{...} +│ └── substack/{...} +├── examples/{image-post.yaml,longform.yaml} +├── scripts/{mmp.py,test_local.py} +└── tests/{core/,integration/} +``` + +## Appendix B: 现有资产盘点(迁移参考) + +| 现有文件 | 处置 | +|---|---| +| `SKILL.md` | 改写:精简内容,引入 `scripts/mmp.py` 入口 + wizard 触发短语 | +| `README.md` | 重写:面向 CC plugin + OpenClaw 双安装说明 | +| `Makefile` | 保留 + 扩展 lint / typecheck target | +| `references/manifest-schema.md` | 替换为 `docs/architecture.md` 的 schema 节 | +| `references/platform-map.md` | 内容拆到各 `providers//provider.yaml` | +| `references/publishing-policy.md` | 升格为 `docs/safety-policy.md` | +| `references/candidate-skills.md` | 保留 in `docs/`;不再是核心引用 | +| `references/workflows.md` | 替换为 `docs/run-lifecycle.md` | +| `references/image-post-mvp.md` | 历史归档 | +| `references/phase2-audit.md` | 历史归档 | +| `references/wechat-image-calibration.md` | 移至 `providers/wechat_image/notes.md` | +| `references/wechat-api-provider.md` | 移至 `providers/wechat_article/notes.md` | +| `scripts/publish_manifest.py` | deprecate;功能归 `mmp validate` + `mmp publish` | +| `scripts/adapt_content.py` | deprecate;功能归 provider.prepare | +| `scripts/prepare_image_post.py` | 拆解到 providers/xiaohongshu + wechat_image | +| `scripts/execute_image_post.py` | 拆解到对应 providers 的 execute | +| `scripts/wechat_api_draft.py` | 移到 `providers/wechat_article/internal/` 作为内部工具 | +| `scripts/prepare_longform.py` | 拆解到 wechat_article + x_article + substack | +| `scripts/test_local.py` | 保留 + 扩展 | +| `examples/*.yaml` | 升级到 schema 0.2 | +| `docs/HANDOFF.md` | 保留作为历史;新增本 design spec | diff --git a/examples/article.md b/examples/article.md new file mode 100644 index 0000000..1f8b806 --- /dev/null +++ b/examples/article.md @@ -0,0 +1,7 @@ +# v0.2 示例长文章 + +这是 multi-media-publisher v0.2 的示例长文章 body 文件。 + +## 章节 + +正文段落。`mmp publish examples/longform.yaml --mode-override dry-run` 应当跑通。 diff --git a/examples/caption.md b/examples/caption.md new file mode 100644 index 0000000..5a2cf83 --- /dev/null +++ b/examples/caption.md @@ -0,0 +1,3 @@ +v0.2 图文示例 caption. + +这是占位文本。注意:image-post 的 xiaohongshu provider 在 Plan 3 才会被实现,所以此 yaml 现在 validate 会报 ProviderNotFoundError — 这是预期的,留作 Plan 3 的契约样例。 diff --git a/examples/cover.png b/examples/cover.png new file mode 100644 index 0000000..66ab6d8 Binary files /dev/null and b/examples/cover.png differ diff --git a/examples/image-post.yaml b/examples/image-post.yaml index 385b692..89260a4 100644 --- a/examples/image-post.yaml +++ b/examples/image-post.yaml @@ -1,16 +1,14 @@ +schema_version: "0.2" type: image-post -title: "AI 创业的三个误区" +title: "v0.2 图文示例" body: ./caption.md -mode: draft +mode: dry-run language: zh-CN targets: - xiaohongshu - - wechat-image assets: images: - - ./01.png - - ./02.png + - ./img-01.png + - ./img-02.png tags: - - AI - - 创业 -cta: "欢迎留言聊聊你的看法。" + - example diff --git a/examples/img-01.png b/examples/img-01.png new file mode 100644 index 0000000..bd5ede1 Binary files /dev/null and b/examples/img-01.png differ diff --git a/examples/img-02.png b/examples/img-02.png new file mode 100644 index 0000000..bd5ede1 Binary files /dev/null and b/examples/img-02.png differ diff --git a/examples/longform-multi.yaml b/examples/longform-multi.yaml new file mode 100644 index 0000000..d8452ec --- /dev/null +++ b/examples/longform-multi.yaml @@ -0,0 +1,18 @@ +schema_version: "0.2" +type: longform +title: "v0.2 multi-target longform example" +body: ./article.md +mode: dry-run +language: zh-CN +targets: + - wechat-article + - x-article + - substack +assets: + cover: ./cover.png +summary: "示例长文章 multi-target 版本" +tags: + - example +metadata: + digest: "公众号摘要 (max 120)" + subtitle: "Substack subtitle" diff --git a/examples/longform.yaml b/examples/longform.yaml index 735813d..253868f 100644 --- a/examples/longform.yaml +++ b/examples/longform.yaml @@ -1,15 +1,15 @@ +schema_version: "0.2" type: longform -title: "AI Agent 不是工具,而是一种新的组织形态" +title: "v0.2 longform example" body: ./article.md -mode: draft +mode: dry-run language: zh-CN targets: - wechat-article - - x-article - - substack assets: cover: ./cover.png +summary: "示例长文章 v0.2" tags: - - AI - - Agent - - 组织 + - example +metadata: + digest: "示例摘要 used as 公众号 digest" diff --git a/providers/__init__.py b/providers/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/providers/substack/__init__.py b/providers/substack/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/providers/substack/provider.py b/providers/substack/provider.py new file mode 100644 index 0000000..345f6b8 --- /dev/null +++ b/providers/substack/provider.py @@ -0,0 +1,92 @@ +"""Substack provider — payload-only stub. + +A real connector (substack-autopilot or generic browser) is not shipped in +v0.2. Behavior parallels x_article: payload + TODO marker + stub +result (mode_actual=stub). +""" + +from __future__ import annotations + +import json +from pathlib import Path +from typing import Any + +from core.provider import ( + CredentialSpec, + ExecutionResult, + HealthStatus, + PreparedPayload, + Provider, + ValidationResult, +) +from providers.substack.rules import SUBSTACK_RULES + + +class SubstackProvider(Provider): + name = "substack" + display_name = "Substack" + media_types = ["longform"] + capabilities = {"draft": True, "publish": False, "schedule": False} + required_credentials = [ + CredentialSpec( + key="SUBSTACK_SESSION_COOKIE", + description="Substack session cookie", + secret=True, + ) + ] + platform_rules = SUBSTACK_RULES + + def validate(self, manifest: Any, target: Any) -> ValidationResult: + return ValidationResult(violations=self.platform_rules.lint(manifest, self.name)) + + def prepare(self, manifest: Any, target: Any, run_dir: Path) -> PreparedPayload: + pack_dir = run_dir / "packs" / self.name + pack_dir.mkdir(parents=True, exist_ok=True) + meta = manifest.metadata or {} + payload = { + "title": manifest.title, + "subtitle": meta.get("subtitle"), + "body": manifest.body, + "cover": manifest.cover, + "tags": list(manifest.tags or []), + "mode": target.mode, + "options": dict(target.options or {}), + } + payload_path = pack_dir / "payload.json" + payload_path.write_text(json.dumps(payload, indent=2, ensure_ascii=False), encoding="utf-8") + (pack_dir / "content.md").write_text(manifest.body or "", encoding="utf-8") + return PreparedPayload(pack_dir=pack_dir, payload_path=payload_path) + + def execute( + self, + run_dir: Path, + target: Any, + mode: str, + credentials: dict[str, str], + ) -> ExecutionResult: + if mode == "publish": + raise NotImplementedError("substack publish path not enabled in v0.2") + if mode == "dry-run": + return ExecutionResult(status="ok", mode_actual="dry-run", external_id=None) + + pack_dir = run_dir / "packs" / self.name + (pack_dir / "TODO-connector.md").write_text( + "# substack connector not implemented in v0.2\n\n" + "Payload is ready at `payload.json`. To complete:\n" + "1. Open https://substack.com/dashboard in a logged-in browser\n" + "2. Click 'New post'\n" + "3. Paste title and subtitle from payload\n" + "4. Paste body from content.md\n" + "5. Set cover from payload.cover (if present)\n" + "6. Save Draft\n", + encoding="utf-8", + ) + return ExecutionResult( + status="ok", + mode_actual="stub", + external_id=None, + extras={"connector_status": "not-implemented"}, + ) + + def health_check(self, credentials: dict[str, str]) -> HealthStatus: + return HealthStatus.unknown diff --git a/providers/substack/provider.yaml b/providers/substack/provider.yaml new file mode 100644 index 0000000..baca1cf --- /dev/null +++ b/providers/substack/provider.yaml @@ -0,0 +1,15 @@ +name: substack +display_name: Substack +media_types: + - longform +capabilities: + draft: true + publish: false + schedule: false +required_credentials: + - key: SUBSTACK_SESSION_COOKIE + description: "Substack session cookie value" + secret: true + setup_hint: "Capture `substack.sid` from a logged-in browser session" +entry: provider:SubstackProvider +schema_version: 1 diff --git a/providers/substack/rules.py b/providers/substack/rules.py new file mode 100644 index 0000000..2e9dcaf --- /dev/null +++ b/providers/substack/rules.py @@ -0,0 +1,33 @@ +"""Platform rules for Substack posts. + +- title <=100 chars practical +- subtitle <=200 chars +- body <=50000 chars +- cover optional +""" + +from __future__ import annotations + +from core.rules import PlatformRules, Severity, Violation + + +def _subtitle_lint(manifest, target_name: str) -> list[Violation]: + subtitle = (manifest.metadata or {}).get("subtitle") or "" + if subtitle and len(subtitle) > 200: + return [ + Violation( + code="SUBSTACK_SUBTITLE_TOO_LONG", + message=f"subtitle length {len(subtitle)} exceeds 200", + target=target_name, + field_path="metadata.subtitle", + severity=Severity.warning, + ) + ] + return [] + + +SUBSTACK_RULES = PlatformRules( + title_max=100, + body_max=50000, + extra_lints=[_subtitle_lint], +) diff --git a/providers/substack/tests/__init__.py b/providers/substack/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/providers/substack/tests/test_provider.py b/providers/substack/tests/test_provider.py new file mode 100644 index 0000000..e97e54c --- /dev/null +++ b/providers/substack/tests/test_provider.py @@ -0,0 +1,67 @@ +import json + +import pytest + +from core.manifest import Manifest, Target +from providers.substack.provider import SubstackProvider + + +@pytest.fixture +def article(): + return Manifest( + schema_version="0.2", + type="longform", + title="A Substack Post", + body="Body text here.", + mode="dry-run", + targets=[Target(name="substack")], + metadata={"subtitle": "An optional subtitle"}, + ) + + +def test_validate_passes(article): + p = SubstackProvider() + res = p.validate(article, article.targets[0]) + assert all(v.severity.value != "error" for v in res.violations) + + +def test_validate_subtitle_too_long(article): + article.metadata["subtitle"] = "x" * 250 + p = SubstackProvider() + res = p.validate(article, article.targets[0]) + codes = [v.code for v in res.violations] + assert "SUBSTACK_SUBTITLE_TOO_LONG" in codes + + +def test_prepare_writes_payload(article, tmp_path): + run_dir = tmp_path / "run" + run_dir.mkdir() + p = SubstackProvider() + out = p.prepare(article, article.targets[0], run_dir) + payload = json.loads(out.payload_path.read_text()) + assert payload["title"] == "A Substack Post" + assert payload["subtitle"] == "An optional subtitle" + + +def test_execute_draft_returns_stub(article, tmp_path): + run_dir = tmp_path / "run" + (run_dir / "packs" / "substack").mkdir(parents=True) + p = SubstackProvider() + p.prepare(article, article.targets[0], run_dir) + res = p.execute( + run_dir, + article.targets[0], + mode="draft", + credentials={"SUBSTACK_SESSION_COOKIE": "stub"}, + ) + assert res.mode_actual == "stub" + assert res.extras.get("connector_status") == "not-implemented" + + +def test_execute_publish_refused(article, tmp_path): + run_dir = tmp_path / "run" + (run_dir / "packs" / "substack").mkdir(parents=True) + p = SubstackProvider() + p.prepare(article, article.targets[0], run_dir) + with pytest.raises(NotImplementedError, match="publish"): + p.execute(run_dir, article.targets[0], mode="publish", credentials={}) diff --git a/providers/wechat_article/__init__.py b/providers/wechat_article/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/providers/wechat_article/internal/__init__.py b/providers/wechat_article/internal/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/providers/wechat_article/internal/wechat_api.py b/providers/wechat_article/internal/wechat_api.py new file mode 100755 index 0000000..4c2b35e --- /dev/null +++ b/providers/wechat_article/internal/wechat_api.py @@ -0,0 +1,228 @@ +"""WeChat Official Account draft API helper. + +Internal module for the wechat_article provider. Draft-only by design; +public publishing / freepublish is intentionally not implemented. + +Public API: + get_access_token(app_id, app_secret) -> str + upload_thumb(token, image_path) -> str # returns media_id + add_draft(token, articles) -> str # returns media_id + draft_from_payload(payload, credentials, dry_run) -> dict +""" + +from __future__ import annotations + +import html +import json +import mimetypes +import os +import pathlib +import urllib.parse +import urllib.request +from typing import Any + +API_BASE = "https://api.weixin.qq.com/cgi-bin" + + +# --------------------------------------------------------------------------- +# Private HTTP helpers +# --------------------------------------------------------------------------- + + +def _post_json(url: str, payload: dict[str, Any]) -> dict[str, Any]: + req = urllib.request.Request( + url, + data=json.dumps(payload, ensure_ascii=False).encode("utf-8"), + headers={"Content-Type": "application/json; charset=utf-8"}, + method="POST", + ) + with urllib.request.urlopen(req, timeout=30) as resp: + return json.loads(resp.read().decode("utf-8")) + + +def _get_json(url: str) -> dict[str, Any]: + with urllib.request.urlopen(url, timeout=30) as resp: + return json.loads(resp.read().decode("utf-8")) + + +def _multipart_upload(url: str, field_name: str, file_path: pathlib.Path) -> dict[str, Any]: + boundary = "----OpenClawMMPBoundary" + ctype = mimetypes.guess_type(str(file_path))[0] or "application/octet-stream" + body = [] + body.append(f"--{boundary}\r\n".encode()) + body.append( + f'Content-Disposition: form-data; name="{field_name}"; filename="{file_path.name}"\r\n'.encode() + ) + body.append(f"Content-Type: {ctype}\r\n\r\n".encode()) + body.append(file_path.read_bytes()) + body.append(f"\r\n--{boundary}--\r\n".encode()) + data = b"".join(body) + req = urllib.request.Request( + url, + data=data, + headers={"Content-Type": f"multipart/form-data; boundary={boundary}"}, + method="POST", + ) + with urllib.request.urlopen(req, timeout=60) as resp: + return json.loads(resp.read().decode("utf-8")) + + +def _env_token() -> str | None: + return os.environ.get("WECHAT_ACCESS_TOKEN") + + +def _local_markdown_to_html(text: str) -> str: + # Minimal safe-ish conversion. Prefer a real Markdown renderer upstream. + parts = [] + for para in text.split("\n\n"): + para = para.strip() + if not para: + continue + if para.startswith("# "): + parts.append(f"

{html.escape(para[2:].strip())}

") + elif para.startswith("## "): + parts.append(f"

{html.escape(para[3:].strip())}

") + else: + parts.append("

" + html.escape(para).replace("\n", "
") + "

") + return "\n".join(parts) + + +def _article_from_payload(payload: dict[str, Any], thumb_media_id: str | None) -> dict[str, Any]: + content = str(payload.get("html") or "") + if not content: + content = _local_markdown_to_html(str(payload.get("content") or "")) + article = { + "title": str(payload.get("title") or "")[:64], + "author": str(payload.get("author") or ""), + "digest": str(payload.get("digest") or "")[:120], + "content": content, + "content_source_url": str(payload.get("content_source_url") or ""), + "thumb_media_id": thumb_media_id or str(payload.get("thumb_media_id") or ""), + "need_open_comment": int(payload.get("need_open_comment") or 0), + "only_fans_can_comment": int(payload.get("only_fans_can_comment") or 0), + } + if not article["title"]: + raise ValueError("title is required") + if not article["content"]: + raise ValueError("content/html is required") + if not article["thumb_media_id"]: + raise ValueError("thumb_media_id is required; provide it or upload a cover image first") + return article + + +# --------------------------------------------------------------------------- +# Public API +# --------------------------------------------------------------------------- + + +def get_access_token(app_id: str, app_secret: str) -> str: + """Fetch a WeChat OA access_token. + + If the environment variable ``WECHAT_ACCESS_TOKEN`` is set, return it + instead of calling the API. + """ + env = _env_token() + if env: + return env + if not app_id or not app_secret: + raise ValueError("app_id and app_secret are required unless WECHAT_ACCESS_TOKEN is set") + url = f"{API_BASE}/token?" + urllib.parse.urlencode( + {"grant_type": "client_credential", "appid": app_id, "secret": app_secret} + ) + data = _get_json(url) + if "access_token" not in data: + raise RuntimeError(f"failed to get access_token: {data}") + return str(data["access_token"]) + + +def upload_thumb(token: str, image_path: pathlib.Path) -> str: + """Upload a cover (thumb) image and return the resulting media_id.""" + if not image_path.exists(): + raise FileNotFoundError(f"cover image not found: {image_path}") + url = f"{API_BASE}/material/add_material?" + urllib.parse.urlencode( + {"access_token": token, "type": "thumb"} + ) + data = _multipart_upload(url, "media", image_path) + if "media_id" not in data: + raise RuntimeError(f"failed to upload thumb: {data}") + return str(data["media_id"]) + + +def add_draft(token: str, articles: list[dict[str, Any]]) -> str: + """Create a draft from a list of article dicts; returns the draft media_id.""" + payload = {"articles": articles} + url = f"{API_BASE}/draft/add?" + urllib.parse.urlencode({"access_token": token}) + data = _post_json(url, payload) + if "media_id" not in data: + raise RuntimeError(f"failed to add draft: {data}") + return str(data["media_id"]) + + +def draft_from_payload( + payload: dict[str, Any], + credentials: dict[str, Any], + dry_run: bool = False, +) -> dict[str, Any]: + """Build and submit a single-article draft from a high-level payload. + + Args: + payload: dict with keys like ``title``, ``content``/``html``, ``author``, + ``digest``, ``cover`` (path) or ``thumb_media_id``, + ``content_source_url``, ``need_open_comment``, + ``only_fans_can_comment``. + credentials: dict that may contain ``app_id``, ``app_secret``, + ``access_token``. + dry_run: if True, no network calls are made; placeholder values are + substituted and the would-be requests are returned. + + Returns: + ``{"ok": True, "upload": , "draft": }`` + """ + app_id = str(credentials.get("app_id") or os.environ.get("WECHAT_APP_ID") or "") + app_secret = str(credentials.get("app_secret") or os.environ.get("WECHAT_APP_SECRET") or "") + explicit_token = credentials.get("access_token") or _env_token() + + if dry_run: + token = str(explicit_token or "DRY_RUN_TOKEN") + else: + token = str(explicit_token) if explicit_token else get_access_token(app_id, app_secret) + + thumb_media_id = payload.get("thumb_media_id") + upload_result: dict[str, Any] | None = None + cover_raw = payload.get("cover") + cover = pathlib.Path(str(cover_raw)).expanduser() if cover_raw and not thumb_media_id else None + + if not thumb_media_id and cover: + if dry_run: + upload_url = f"{API_BASE}/material/add_material?" + urllib.parse.urlencode( + {"access_token": token, "type": "thumb"} + ) + upload_result = { + "ok": True, + "dry_run": True, + "method": "POST multipart", + "url": upload_url.replace(token, "***"), + "file": str(cover), + } + thumb_media_id = "DRY_RUN_THUMB_MEDIA_ID" + else: + media_id = upload_thumb(token, cover) + upload_result = {"ok": True, "media_id": media_id} + thumb_media_id = media_id + + article = _article_from_payload(payload, thumb_media_id) + + if dry_run: + draft_url = f"{API_BASE}/draft/add?" + urllib.parse.urlencode({"access_token": token}) + draft_result: dict[str, Any] = { + "ok": True, + "dry_run": True, + "method": "POST", + "url": draft_url.replace(token, "***"), + "payload": {"articles": [article]}, + } + else: + media_id = add_draft(token, [article]) + draft_result = {"ok": True, "media_id": media_id} + + return {"ok": True, "upload": upload_result, "draft": draft_result} diff --git a/references/wechat-api-provider.md b/providers/wechat_article/notes.md similarity index 100% rename from references/wechat-api-provider.md rename to providers/wechat_article/notes.md diff --git a/providers/wechat_article/provider.py b/providers/wechat_article/provider.py new file mode 100644 index 0000000..e214264 --- /dev/null +++ b/providers/wechat_article/provider.py @@ -0,0 +1,167 @@ +"""WeChat Official Account article provider.""" + +from __future__ import annotations + +import json +from pathlib import Path +from typing import Any + +from core.errors import ProviderExecutionError +from core.provider import ( + CredentialSpec, + ExecutionResult, + HealthStatus, + PreparedPayload, + Provider, + ValidationResult, +) +from providers.wechat_article.rules import WECHAT_ARTICLE_RULES + + +def _markdown_to_html(md: str) -> str: + """Minimal MD->HTML for WeChat draft API smoke. Not a full renderer.""" + out_lines: list[str] = [] + for line in md.splitlines(): + if line.startswith("# "): + out_lines.append(f"

{line[2:].strip()}

") + elif line.startswith("## "): + out_lines.append(f"

{line[3:].strip()}

") + elif line.startswith("### "): + out_lines.append(f"

{line[4:].strip()}

") + elif not line.strip(): + out_lines.append("") + else: + out_lines.append(f"

{line}

") + return "\n".join(out_lines) + + +class WeChatArticleProvider(Provider): + name = "wechat-article" + display_name = "微信公众号文章" + media_types = ["longform"] + capabilities = {"draft": True, "publish": False, "schedule": False} + required_credentials = [ + CredentialSpec( + key="WECHAT_APP_ID", + description="WeChat Official Account AppID", + secret=False, + setup_hint="From mp.weixin.qq.com → 设置与开发 → 基本配置", + ), + CredentialSpec( + key="WECHAT_APP_SECRET", + description="WeChat Official Account AppSecret", + secret=True, + setup_hint="Same page as AppID; reset if forgotten", + ), + ] + platform_rules = WECHAT_ARTICLE_RULES + + def validate(self, manifest: Any, target: Any) -> ValidationResult: + violations = self.platform_rules.lint(manifest, target_name=self.name) + return ValidationResult(violations=violations) + + def prepare(self, manifest: Any, target: Any, run_dir: Path) -> PreparedPayload: + pack_dir = run_dir / "packs" / self.name + pack_dir.mkdir(parents=True, exist_ok=True) + + digest = (manifest.metadata or {}).get("digest") or (manifest.summary or "") + body_md = manifest.body or "" + body_html = _markdown_to_html(body_md) + + payload = { + "title": manifest.title, + "content": body_md, + "html": body_html, + "digest": digest, + "tags": list(manifest.tags or []), + "cover": str(manifest.cover) if manifest.cover else None, + "mode": target.mode, + "options": dict(target.options or {}), + } + payload_path = pack_dir / "payload.json" + payload_path.write_text(json.dumps(payload, indent=2, ensure_ascii=False), encoding="utf-8") + (pack_dir / "content.md").write_text(body_md, encoding="utf-8") + + return PreparedPayload(pack_dir=pack_dir, payload_path=payload_path) + + def execute( + self, + run_dir: Path, + target: Any, + mode: str, + credentials: dict[str, str], + ) -> ExecutionResult: + if mode == "publish": + raise NotImplementedError( + "wechat-article publish path not enabled in v0.2; use mode=draft" + ) + + if mode == "dry-run": + return ExecutionResult(status="ok", mode_actual="dry-run", external_id=None) + + # mode == "draft" + from providers.wechat_article.internal import wechat_api # local import: optional dep + + payload_path = run_dir / "packs" / self.name / "payload.json" + payload = json.loads(payload_path.read_text(encoding="utf-8")) + + app_id = credentials.get("WECHAT_APP_ID") + app_secret = credentials.get("WECHAT_APP_SECRET") + if not app_id or not app_secret: + raise ProviderExecutionError( + target=self.name, + step="auth", + upstream=ValueError("missing WECHAT_APP_ID or WECHAT_APP_SECRET"), + retryable=False, + ) + + try: + token = wechat_api.get_access_token(app_id, app_secret) + except Exception as exc: + raise ProviderExecutionError( + target=self.name, step="get_token", upstream=exc, retryable=True + ) from exc + + try: + thumb_media_id = wechat_api.upload_thumb(token, Path(payload["cover"])) + except Exception as exc: + raise ProviderExecutionError( + target=self.name, step="upload_thumb", upstream=exc, retryable=True + ) from exc + + article = { + "title": payload["title"], + "thumb_media_id": thumb_media_id, + "content": payload["html"], + "digest": payload["digest"], + "show_cover_pic": 1, + "need_open_comment": 0, + "only_fans_can_comment": 0, + } + + try: + draft_id = wechat_api.add_draft(token, [article]) + except Exception as exc: + raise ProviderExecutionError( + target=self.name, step="add_draft", upstream=exc, retryable=True + ) from exc + + return ExecutionResult( + status="ok", + mode_actual="draft-platform", + external_id=draft_id, + extras={"thumb_media_id": thumb_media_id}, + ) + + def health_check(self, credentials: dict[str, str]) -> HealthStatus: + from providers.wechat_article.internal import wechat_api + + app_id = credentials.get("WECHAT_APP_ID") + app_secret = credentials.get("WECHAT_APP_SECRET") + if not (app_id and app_secret): + return HealthStatus.failed + try: + wechat_api.get_access_token(app_id, app_secret) + return HealthStatus.ok + except Exception: + return HealthStatus.failed diff --git a/providers/wechat_article/provider.yaml b/providers/wechat_article/provider.yaml new file mode 100644 index 0000000..35943ab --- /dev/null +++ b/providers/wechat_article/provider.yaml @@ -0,0 +1,19 @@ +name: wechat-article +display_name: 微信公众号文章 +media_types: + - longform +capabilities: + draft: true + publish: false + schedule: false +required_credentials: + - key: WECHAT_APP_ID + description: "WeChat Official Account AppID" + secret: false + setup_hint: "From mp.weixin.qq.com → 设置与开发 → 基本配置" + - key: WECHAT_APP_SECRET + description: "WeChat Official Account AppSecret" + secret: true + setup_hint: "Same page as AppID; reset if forgotten" +entry: provider:WeChatArticleProvider +schema_version: 1 diff --git a/providers/wechat_article/rules.py b/providers/wechat_article/rules.py new file mode 100644 index 0000000..d6a8ced --- /dev/null +++ b/providers/wechat_article/rules.py @@ -0,0 +1,49 @@ +"""Platform rules for WeChat Official Account articles. + +Sources: +- 微信公众号文章正文长度上限 ~20000 中文字符 +- 标题最多 64 字符 +- 摘要最多 120 字符 +- 必须有封面图(thumb_media_id) +""" + +from __future__ import annotations + +from core.rules import PlatformRules, Severity, Violation + + +def _digest_lint(manifest, target_name: str) -> list[Violation]: + digest = (manifest.metadata or {}).get("digest") or (manifest.summary or "") + if digest and len(digest) > 120: + return [ + Violation( + code="WECHAT_DIGEST_TOO_LONG", + message=f"digest length {len(digest)} exceeds 120 chars", + target=target_name, + field_path="metadata.digest", + severity=Severity.warning, + ) + ] + return [] + + +def _cover_lint(manifest, target_name: str) -> list[Violation]: + if not getattr(manifest, "cover", None): + return [ + Violation( + code="WECHAT_COVER_REQUIRED", + message="WeChat article requires a cover image (assets.cover)", + target=target_name, + field_path="assets.cover", + severity=Severity.error, + ) + ] + return [] + + +WECHAT_ARTICLE_RULES = PlatformRules( + title_max=64, + body_max=20000, + cover_required=True, + extra_lints=[_digest_lint, _cover_lint], +) diff --git a/providers/wechat_article/tests/__init__.py b/providers/wechat_article/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/providers/wechat_article/tests/test_provider.py b/providers/wechat_article/tests/test_provider.py new file mode 100644 index 0000000..1ba261e --- /dev/null +++ b/providers/wechat_article/tests/test_provider.py @@ -0,0 +1,109 @@ +import json +from pathlib import Path +from unittest.mock import patch + +import pytest + +from core.manifest import Manifest, Target +from providers.wechat_article.provider import WeChatArticleProvider + + +@pytest.fixture +def sample_manifest(tmp_path): + return Manifest( + schema_version="0.2", + type="longform", + title="A reasonable title", + body="# Hello\n\nBody text.", + mode="dry-run", + targets=[Target(name="wechat-article")], + cover=str(tmp_path / "cover.png"), + summary="一句话摘要", + tags=["test"], + ) + + +def test_validate_passes(sample_manifest, tmp_path): + cover = Path(sample_manifest.cover) + cover.write_bytes(b"png-bytes") + p = WeChatArticleProvider() + res = p.validate(sample_manifest, sample_manifest.targets[0]) + assert all(v.severity.value != "error" for v in res.violations) + + +def test_validate_fails_when_no_cover(sample_manifest): + sample_manifest.cover = None + p = WeChatArticleProvider() + res = p.validate(sample_manifest, sample_manifest.targets[0]) + assert any(v.code == "WECHAT_COVER_REQUIRED" for v in res.violations) + + +def test_prepare_writes_payload(sample_manifest, tmp_path): + cover = Path(sample_manifest.cover) + cover.write_bytes(b"png-bytes") + run_dir = tmp_path / "run1" + run_dir.mkdir() + p = WeChatArticleProvider() + out = p.prepare(sample_manifest, sample_manifest.targets[0], run_dir) + assert out.payload_path.exists() + payload = json.loads(out.payload_path.read_text()) + assert payload["title"] == "A reasonable title" + assert payload["cover"].endswith("cover.png") + assert "html" in payload + assert "content" in payload + + +def test_execute_dry_run_writes_pseudo_draft(sample_manifest, tmp_path): + cover = Path(sample_manifest.cover) + cover.write_bytes(b"png") + run_dir = tmp_path / "run2" + (run_dir / "packs" / "wechat-article").mkdir(parents=True) + p = WeChatArticleProvider() + p.prepare(sample_manifest, sample_manifest.targets[0], run_dir) + + res = p.execute(run_dir, sample_manifest.targets[0], mode="dry-run", credentials={}) + assert res.status == "ok" + assert res.mode_actual == "dry-run" + assert res.external_id is None + + +def test_execute_draft_calls_api(sample_manifest, tmp_path): + cover = Path(sample_manifest.cover) + cover.write_bytes(b"png") + run_dir = tmp_path / "run3" + (run_dir / "packs" / "wechat-article").mkdir(parents=True) + p = WeChatArticleProvider() + p.prepare(sample_manifest, sample_manifest.targets[0], run_dir) + + creds = {"WECHAT_APP_ID": "wx", "WECHAT_APP_SECRET": "s"} + with ( + patch( + "providers.wechat_article.internal.wechat_api.get_access_token", + return_value="tok-123", + ), + patch( + "providers.wechat_article.internal.wechat_api.upload_thumb", + return_value="thumb-id-1", + ), + patch( + "providers.wechat_article.internal.wechat_api.add_draft", + return_value="draft-id-9", + ), + ): + res = p.execute(run_dir, sample_manifest.targets[0], mode="draft", credentials=creds) + + assert res.status == "ok" + assert res.mode_actual == "draft-platform" + assert res.external_id == "draft-id-9" + + +def test_execute_publish_refused(sample_manifest, tmp_path): + cover = Path(sample_manifest.cover) + cover.write_bytes(b"png") + run_dir = tmp_path / "run4" + (run_dir / "packs" / "wechat-article").mkdir(parents=True) + p = WeChatArticleProvider() + p.prepare(sample_manifest, sample_manifest.targets[0], run_dir) + + with pytest.raises(NotImplementedError, match="publish"): + p.execute(run_dir, sample_manifest.targets[0], mode="publish", credentials={"a": "b"}) diff --git a/providers/wechat_image/__init__.py b/providers/wechat_image/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/references/wechat-image-calibration.md b/providers/wechat_image/notes.md similarity index 100% rename from references/wechat-image-calibration.md rename to providers/wechat_image/notes.md diff --git a/providers/wechat_image/provider.py b/providers/wechat_image/provider.py new file mode 100644 index 0000000..7d156cd --- /dev/null +++ b/providers/wechat_image/provider.py @@ -0,0 +1,119 @@ +"""WeChat 图文内容 provider — emits a browser-flow guide for the user. + +The browser path to mp.weixin.qq.com is currently blocked under OpenClaw policy, +so this provider does NOT automate the upload. Instead, it produces a +step-by-step Markdown guide the user follows in their own browser. +""" + +from __future__ import annotations + +import json +from pathlib import Path +from typing import Any + +from core.provider import ( + ExecutionResult, + HealthStatus, + PreparedPayload, + Provider, + ValidationResult, +) +from providers.wechat_image.rules import WECHAT_IMAGE_RULES + +_GUIDE_TEMPLATE = """\ +# WeChat 图文内容 — 手动执行指南 + +> 自动化未启用:浏览器对 mp.weixin.qq.com 的访问被策略阻止。请按下面的步骤手动完成。 + +## Payload + +文件:`{payload_path}` + +字段: + +- **标题**:`{title}` +- **正文**:见 `content.md` +- **图片**({n_images} 张): +{image_list} +- **CTA**:{cta} + +## 操作步骤 + +1. 打开 https://mp.weixin.qq.com/ 并登录目标公众号。 +2. 顶部菜单选择 **图文素材** → **新建图文素材**(图文内容)。 +3. 填入标题、摘要(如有)。 +4. 把上面列出的每张图片按顺序上传。 +5. 把 `content.md` 内容粘贴到正文。 +6. 点击 **保存草稿**。**不要点发布**。 +7. 回到这里继续后续动作或确认草稿状态。 + +## 安全提示 + +- 不要绕过登录或验证码。 +- 不要导出 cookie 文件到任何外部位置。 +- 草稿确认后,如需公开发布,使用公众号原生的"群发"功能。 +""" + + +class WeChatImageProvider(Provider): + name = "wechat-image" + display_name = "微信图文内容" + media_types = ["image-post"] + capabilities = {"draft": True, "publish": False, "schedule": False} + required_credentials = [] + platform_rules = WECHAT_IMAGE_RULES + + def validate(self, manifest: Any, target: Any) -> ValidationResult: + return ValidationResult(violations=self.platform_rules.lint(manifest, self.name)) + + def prepare(self, manifest: Any, target: Any, run_dir: Path) -> PreparedPayload: + pack_dir = run_dir / "packs" / self.name + pack_dir.mkdir(parents=True, exist_ok=True) + payload = { + "title": manifest.title, + "caption": manifest.body, + "images": list(manifest.images or []), + "cta": manifest.cta, + "mode": target.mode, + "options": dict(target.options or {}), + } + payload_path = pack_dir / "payload.json" + payload_path.write_text(json.dumps(payload, indent=2, ensure_ascii=False), encoding="utf-8") + (pack_dir / "content.md").write_text(manifest.body or "", encoding="utf-8") + return PreparedPayload(pack_dir=pack_dir, payload_path=payload_path) + + def execute( + self, + run_dir: Path, + target: Any, + mode: str, + credentials: dict[str, str], + ) -> ExecutionResult: + if mode == "publish": + raise NotImplementedError("wechat-image publish path not supported in v0.2") + if mode == "dry-run": + return ExecutionResult(status="ok", mode_actual="dry-run", external_id=None) + + # mode == draft → write a browser-flow guide + pack_dir = run_dir / "packs" / self.name + payload_path = pack_dir / "payload.json" + payload = json.loads(payload_path.read_text(encoding="utf-8")) + image_list = "\n".join(f" - `{img}`" for img in payload["images"]) or " - (none)" + guide = _GUIDE_TEMPLATE.format( + payload_path=str(payload_path), + title=payload["title"], + n_images=len(payload["images"]), + image_list=image_list, + cta=payload.get("cta") or "(none)", + ) + guide_path = pack_dir / "browser-flow.md" + guide_path.write_text(guide, encoding="utf-8") + return ExecutionResult( + status="ok", + mode_actual="draft-local", + external_id=None, + extras={"guide_path": str(guide_path)}, + ) + + def health_check(self, credentials: dict[str, str]) -> HealthStatus: + return HealthStatus.unknown diff --git a/providers/wechat_image/provider.yaml b/providers/wechat_image/provider.yaml new file mode 100644 index 0000000..2522e09 --- /dev/null +++ b/providers/wechat_image/provider.yaml @@ -0,0 +1,11 @@ +name: wechat-image +display_name: 微信图文内容 +media_types: + - image-post +capabilities: + draft: true + publish: false + schedule: false +required_credentials: [] +entry: provider:WeChatImageProvider +schema_version: 1 diff --git a/providers/wechat_image/rules.py b/providers/wechat_image/rules.py new file mode 100644 index 0000000..e613c20 --- /dev/null +++ b/providers/wechat_image/rules.py @@ -0,0 +1,18 @@ +"""Platform rules for WeChat image posts (图文内容, not OA articles). + +Approximate limits (refine when browser flow lands): +- title up to ~64 chars +- caption up to ~600 chars +- images 1..9 +""" + +from __future__ import annotations + +from core.rules import PlatformRules + +WECHAT_IMAGE_RULES = PlatformRules( + title_max=64, + body_max=600, + image_count_min=1, + image_count_max=9, +) diff --git a/providers/wechat_image/tests/__init__.py b/providers/wechat_image/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/providers/wechat_image/tests/test_provider.py b/providers/wechat_image/tests/test_provider.py new file mode 100644 index 0000000..318e3de --- /dev/null +++ b/providers/wechat_image/tests/test_provider.py @@ -0,0 +1,69 @@ +import json + +import pytest + +from core.manifest import Manifest, Target +from providers.wechat_image.provider import WeChatImageProvider + + +@pytest.fixture +def img_manifest(tmp_path): + img = tmp_path / "01.png" + img.write_bytes(b"png") + return Manifest( + schema_version="0.2", + type="image-post", + title="短", + body="caption text", + mode="dry-run", + targets=[Target(name="wechat-image")], + images=[str(img)], + ) + + +def test_validate(img_manifest): + p = WeChatImageProvider() + res = p.validate(img_manifest, img_manifest.targets[0]) + assert all(v.severity.value != "error" for v in res.violations) + + +def test_prepare_writes_payload(img_manifest, tmp_path): + run_dir = tmp_path / "run" + run_dir.mkdir() + p = WeChatImageProvider() + out = p.prepare(img_manifest, img_manifest.targets[0], run_dir) + assert out.payload_path.exists() + payload = json.loads(out.payload_path.read_text()) + assert payload["title"] == "短" + assert payload["caption"] == "caption text" + + +def test_execute_draft_writes_browser_flow_guide(img_manifest, tmp_path): + run_dir = tmp_path / "run" + (run_dir / "packs" / "wechat-image").mkdir(parents=True) + p = WeChatImageProvider() + p.prepare(img_manifest, img_manifest.targets[0], run_dir) + res = p.execute(run_dir, img_manifest.targets[0], mode="draft", credentials={}) + guide = run_dir / "packs" / "wechat-image" / "browser-flow.md" + assert guide.exists() + assert "mp.weixin.qq.com" in guide.read_text() + assert res.status == "ok" + assert res.mode_actual == "draft-local" + + +def test_execute_dry_run_skips_guide(img_manifest, tmp_path): + run_dir = tmp_path / "run" + (run_dir / "packs" / "wechat-image").mkdir(parents=True) + p = WeChatImageProvider() + p.prepare(img_manifest, img_manifest.targets[0], run_dir) + res = p.execute(run_dir, img_manifest.targets[0], mode="dry-run", credentials={}) + assert res.mode_actual == "dry-run" + + +def test_execute_publish_refused(img_manifest, tmp_path): + run_dir = tmp_path / "run" + (run_dir / "packs" / "wechat-image").mkdir(parents=True) + p = WeChatImageProvider() + p.prepare(img_manifest, img_manifest.targets[0], run_dir) + with pytest.raises(NotImplementedError, match="publish"): + p.execute(run_dir, img_manifest.targets[0], mode="publish", credentials={}) diff --git a/providers/x_article/__init__.py b/providers/x_article/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/providers/x_article/provider.py b/providers/x_article/provider.py new file mode 100644 index 0000000..86fec99 --- /dev/null +++ b/providers/x_article/provider.py @@ -0,0 +1,92 @@ +"""X Articles provider — payload-only stub. + +A real connector (likely the `x-articles` skill or browser automation) is not +shipped in v0.2. `execute` writes a TODO marker into the run dir and returns +mode_actual=stub, so multi-target manifests can still progress past this +target without failing the run. +""" + +from __future__ import annotations + +import json +from pathlib import Path +from typing import Any + +from core.provider import ( + CredentialSpec, + ExecutionResult, + HealthStatus, + PreparedPayload, + Provider, + ValidationResult, +) +from providers.x_article.rules import X_ARTICLE_RULES + + +class XArticleProvider(Provider): + name = "x-article" + display_name = "X Articles" + media_types = ["longform"] + capabilities = {"draft": True, "publish": False, "schedule": False} + required_credentials = [ + CredentialSpec( + key="X_AUTH_TOKEN", + description="X (Twitter) auth token", + secret=True, + ) + ] + platform_rules = X_ARTICLE_RULES + + def validate(self, manifest: Any, target: Any) -> ValidationResult: + return ValidationResult(violations=self.platform_rules.lint(manifest, self.name)) + + def prepare(self, manifest: Any, target: Any, run_dir: Path) -> PreparedPayload: + pack_dir = run_dir / "packs" / self.name + pack_dir.mkdir(parents=True, exist_ok=True) + payload = { + "title": manifest.title, + "body": manifest.body, + "summary": manifest.summary, + "cover": manifest.cover, + "tags": list(manifest.tags or []), + "mode": target.mode, + "options": dict(target.options or {}), + } + payload_path = pack_dir / "payload.json" + payload_path.write_text(json.dumps(payload, indent=2, ensure_ascii=False), encoding="utf-8") + (pack_dir / "content.md").write_text(manifest.body or "", encoding="utf-8") + return PreparedPayload(pack_dir=pack_dir, payload_path=payload_path) + + def execute( + self, + run_dir: Path, + target: Any, + mode: str, + credentials: dict[str, str], + ) -> ExecutionResult: + if mode == "publish": + raise NotImplementedError("x-article publish path not enabled in v0.2") + if mode == "dry-run": + return ExecutionResult(status="ok", mode_actual="dry-run", external_id=None) + + # mode == draft, no real connector yet + pack_dir = run_dir / "packs" / self.name + (pack_dir / "TODO-connector.md").write_text( + "# x-article connector not implemented in v0.2\n\n" + "Payload is ready at `payload.json`. To complete the draft:\n" + "1. Open https://x.com/i/articles/compose in a logged-in browser\n" + "2. Paste title from payload.title\n" + "3. Paste body from content.md\n" + "4. Set cover from payload.cover (if present)\n" + "5. Save Draft\n", + encoding="utf-8", + ) + return ExecutionResult( + status="ok", + mode_actual="stub", + external_id=None, + extras={"connector_status": "not-implemented"}, + ) + + def health_check(self, credentials: dict[str, str]) -> HealthStatus: + return HealthStatus.unknown diff --git a/providers/x_article/provider.yaml b/providers/x_article/provider.yaml new file mode 100644 index 0000000..b683a46 --- /dev/null +++ b/providers/x_article/provider.yaml @@ -0,0 +1,15 @@ +name: x-article +display_name: X Articles +media_types: + - longform +capabilities: + draft: true + publish: false + schedule: false +required_credentials: + - key: X_AUTH_TOKEN + description: "X (Twitter) auth token cookie value" + secret: true + setup_hint: "Capture from a logged-in browser session; rotate after testing" +entry: provider:XArticleProvider +schema_version: 1 diff --git a/providers/x_article/rules.py b/providers/x_article/rules.py new file mode 100644 index 0000000..c2a9b84 --- /dev/null +++ b/providers/x_article/rules.py @@ -0,0 +1,16 @@ +"""Platform rules for X Articles. + +Approximate (X Articles are evolving): +- title <=70 chars practical +- body <=25000 chars +- cover optional +""" + +from __future__ import annotations + +from core.rules import PlatformRules + +X_ARTICLE_RULES = PlatformRules( + title_max=70, + body_max=25000, +) diff --git a/providers/x_article/tests/__init__.py b/providers/x_article/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/providers/x_article/tests/test_provider.py b/providers/x_article/tests/test_provider.py new file mode 100644 index 0000000..5254434 --- /dev/null +++ b/providers/x_article/tests/test_provider.py @@ -0,0 +1,77 @@ +import json + +import pytest + +from core.manifest import Manifest, Target +from providers.x_article.provider import XArticleProvider + + +@pytest.fixture +def article(tmp_path): + return Manifest( + schema_version="0.2", + type="longform", + title="An X Article", + body="# Heading\n\nBody.", + mode="dry-run", + targets=[Target(name="x-article")], + summary="A summary", + tags=["tech"], + ) + + +def test_validate(article): + p = XArticleProvider() + res = p.validate(article, article.targets[0]) + assert all(v.severity.value != "error" for v in res.violations) + + +def test_validate_title_too_long(article): + article.title = "x" * 200 + p = XArticleProvider() + res = p.validate(article, article.targets[0]) + assert any(v.code == "TITLE_TOO_LONG" for v in res.violations) + + +def test_prepare_writes_payload(article, tmp_path): + run_dir = tmp_path / "run" + run_dir.mkdir() + p = XArticleProvider() + out = p.prepare(article, article.targets[0], run_dir) + payload = json.loads(out.payload_path.read_text()) + assert payload["title"] == "An X Article" + assert payload["body"].startswith("# Heading") + + +def test_execute_dry_run(article, tmp_path): + run_dir = tmp_path / "run" + (run_dir / "packs" / "x-article").mkdir(parents=True) + p = XArticleProvider() + p.prepare(article, article.targets[0], run_dir) + res = p.execute(run_dir, article.targets[0], mode="dry-run", credentials={}) + assert res.mode_actual == "dry-run" + + +def test_execute_draft_returns_stub(article, tmp_path): + """v0.2: no real X connector. Draft falls back to stub result with TODO note.""" + run_dir = tmp_path / "run" + (run_dir / "packs" / "x-article").mkdir(parents=True) + p = XArticleProvider() + p.prepare(article, article.targets[0], run_dir) + res = p.execute( + run_dir, + article.targets[0], + mode="draft", + credentials={"X_AUTH_TOKEN": "stub"}, + ) + assert res.mode_actual == "stub" + assert res.extras.get("connector_status") == "not-implemented" + + +def test_execute_publish_refused(article, tmp_path): + run_dir = tmp_path / "run" + (run_dir / "packs" / "x-article").mkdir(parents=True) + p = XArticleProvider() + p.prepare(article, article.targets[0], run_dir) + with pytest.raises(NotImplementedError, match="publish"): + p.execute(run_dir, article.targets[0], mode="publish", credentials={}) diff --git a/providers/xiaohongshu/__init__.py b/providers/xiaohongshu/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/providers/xiaohongshu/provider.py b/providers/xiaohongshu/provider.py new file mode 100644 index 0000000..397769b --- /dev/null +++ b/providers/xiaohongshu/provider.py @@ -0,0 +1,175 @@ +"""Xiaohongshu provider — local draft via the xiaohongshu skill's draft.sh. + +This v0.2 provider only knows the `draft-local` path: it shells out to the +local xiaohongshu skill's `draft.sh`, which writes a JSON draft file to +``~/.xiaohongshu/drafts/`` (or ``XHS_DRAFT_DIR``). No platform upload, no API +call, no cookie required. + +Platform draft and publish are deferred to v0.3. + +draft.sh contract (verified empirically against the real script): +- Input: a single positional argument that is a JSON string with fields + ``title``, ``content``, ``images`` (absolute paths), ``tags``, optional ``video``. +- Output: human-readable lines on stdout, starting with + ``✓ 已创建本地草稿: ``. +- Side effect: a draft JSON file at ``$XHS_DRAFT_DIR/-.json``. + +Discovery candidates (in order; first match wins): +1. ``$XHS_DRAFT_SH`` env var (full path to draft.sh) +2. ``~/.openclaw/workspace/skills/xiaohongshu/scripts/draft.sh`` (workspace install) +3. ``~/.openclaw/skills/xiaohongshu/scripts/draft.sh`` (legacy install) +4. ``~/.config/mmp/skills/xiaohongshu/scripts/draft.sh`` (mmp-managed install) +""" + +from __future__ import annotations + +import json +import os +import re +import subprocess +from pathlib import Path +from typing import Any + +from core.errors import ProviderExecutionError +from core.provider import ( + CredentialSpec, + ExecutionResult, + HealthStatus, + PreparedPayload, + Provider, + ValidationResult, +) +from providers.xiaohongshu.rules import XHS_RULES + +_DRAFT_PATH_RE = re.compile(r"已创建本地草稿:\s*(.+)") + + +def _locate_draft_sh() -> Path | None: + env = os.environ.get("XHS_DRAFT_SH", "").strip() + if env: + p = Path(env).expanduser() + return p if p.exists() else None + candidates = [ + Path.home() / ".openclaw" / "workspace" / "skills" / "xiaohongshu" / "scripts" / "draft.sh", + Path.home() / ".openclaw" / "skills" / "xiaohongshu" / "scripts" / "draft.sh", + Path.home() / ".config" / "mmp" / "skills" / "xiaohongshu" / "scripts" / "draft.sh", + ] + return next((c for c in candidates if c.exists()), None) + + +def _build_xhs_payload(payload: dict[str, Any]) -> dict[str, Any]: + """Reshape mmp's payload.json into the JSON draft.sh expects.""" + out: dict[str, Any] = { + "title": payload.get("title", ""), + "content": payload.get("caption", "") or payload.get("content", ""), + "images": list(payload.get("images") or []), + "tags": list(payload.get("tags") or []), + } + video = payload.get("video") + if video: + out["video"] = video + return out + + +def _invoke_local_draft(payload: dict[str, Any]) -> dict[str, Any]: + """Call the xiaohongshu skill's draft.sh with a JSON string arg. + + Returns ``{"draft_id": str, "draft_path": str}``. + """ + script = _locate_draft_sh() + if script is None: + raise FileNotFoundError( + "xiaohongshu draft.sh not found. Set XHS_DRAFT_SH env var or install " + "the xiaohongshu skill at one of the standard locations." + ) + + xhs_json = json.dumps(_build_xhs_payload(payload), ensure_ascii=False) + result = subprocess.run( + [str(script), xhs_json], + capture_output=True, + text=True, + ) + if result.returncode != 0: + raise RuntimeError(f"draft.sh failed (exit {result.returncode}): {result.stderr.strip()}") + + # Parse stdout for the draft path line. Format: + # ✓ 已创建本地草稿: /path/to/draft.json + draft_path: str | None = None + for line in result.stdout.splitlines(): + m = _DRAFT_PATH_RE.search(line) + if m: + draft_path = m.group(1).strip() + break + if draft_path is None: + raise RuntimeError(f"draft.sh did not report a draft path. stdout was:\n{result.stdout}") + + # draft_id = the file's basename without extension + draft_id = Path(draft_path).stem + return {"draft_id": draft_id, "draft_path": draft_path} + + +class XiaohongshuProvider(Provider): + name = "xiaohongshu" + display_name = "小红书" + media_types = ["image-post", "video-post"] + capabilities = {"draft": True, "publish": False, "schedule": False} + # Local draft path needs no credentials. XHS_COOKIE_PATH listed for v0.3 + # platform-draft / publish flow; not required by current `draft-local`. + required_credentials: list[CredentialSpec] = [] + platform_rules = XHS_RULES + + def validate(self, manifest: Any, target: Any) -> ValidationResult: + return ValidationResult(violations=self.platform_rules.lint(manifest, self.name)) + + def prepare(self, manifest: Any, target: Any, run_dir: Path) -> PreparedPayload: + pack_dir = run_dir / "packs" / self.name + pack_dir.mkdir(parents=True, exist_ok=True) + + payload = { + "title": manifest.title, + "caption": manifest.body, + "images": list(manifest.images or []), + "tags": list(manifest.tags or []), + "cta": manifest.cta, + "mode": target.mode, + "options": dict(target.options or {}), + } + payload_path = pack_dir / "payload.json" + payload_path.write_text(json.dumps(payload, indent=2, ensure_ascii=False), encoding="utf-8") + (pack_dir / "content.md").write_text(manifest.body or "", encoding="utf-8") + return PreparedPayload(pack_dir=pack_dir, payload_path=payload_path) + + def execute( + self, + run_dir: Path, + target: Any, + mode: str, + credentials: dict[str, str], + ) -> ExecutionResult: + if mode == "publish": + raise NotImplementedError( + "xiaohongshu publish path not enabled in v0.2; use mode=draft" + ) + if mode == "dry-run": + return ExecutionResult(status="ok", mode_actual="dry-run", external_id=None) + + # mode == draft → invoke local draft.sh, no creds needed + payload_path = run_dir / "packs" / self.name / "payload.json" + payload = json.loads(payload_path.read_text(encoding="utf-8")) + try: + out = _invoke_local_draft(payload) + except Exception as exc: + raise ProviderExecutionError( + target=self.name, step="local_draft", upstream=exc, retryable=True + ) from exc + + return ExecutionResult( + status="ok", + mode_actual="draft-local", + external_id=out.get("draft_id"), + extras={"draft_path": out.get("draft_path")}, + ) + + def health_check(self, credentials: dict[str, str]) -> HealthStatus: + # Local draft only needs draft.sh present; no creds. + return HealthStatus.ok if _locate_draft_sh() is not None else HealthStatus.failed diff --git a/providers/xiaohongshu/provider.yaml b/providers/xiaohongshu/provider.yaml new file mode 100644 index 0000000..38d03eb --- /dev/null +++ b/providers/xiaohongshu/provider.yaml @@ -0,0 +1,16 @@ +name: xiaohongshu +display_name: 小红书 +media_types: + - image-post + - video-post +capabilities: + draft: true + publish: false + schedule: false +required_credentials: + - key: XHS_COOKIE_PATH + description: "Path to xiaohongshu cookies file (JSON)" + secret: false + setup_hint: "Use the xiaohongshu skill's `xhs-login` flow to capture cookies; default path `~/.config/mmp/cookies/xhs.json`" +entry: provider:XiaohongshuProvider +schema_version: 1 diff --git a/providers/xiaohongshu/rules.py b/providers/xiaohongshu/rules.py new file mode 100644 index 0000000..2e92d1c --- /dev/null +++ b/providers/xiaohongshu/rules.py @@ -0,0 +1,20 @@ +"""Platform rules for Xiaohongshu image posts. + +Sources (current MCP docs): +- title <= 20 chars +- body (caption) <= 1000 chars +- images 1..9 +- tags max 10 (soft warning above) +""" + +from __future__ import annotations + +from core.rules import PlatformRules + +XHS_RULES = PlatformRules( + title_max=20, + body_max=1000, + image_count_min=1, + image_count_max=9, + tag_max=10, +) diff --git a/providers/xiaohongshu/tests/__init__.py b/providers/xiaohongshu/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/providers/xiaohongshu/tests/test_provider.py b/providers/xiaohongshu/tests/test_provider.py new file mode 100644 index 0000000..09706e6 --- /dev/null +++ b/providers/xiaohongshu/tests/test_provider.py @@ -0,0 +1,146 @@ +import json +from unittest.mock import patch + +import pytest + +from core.manifest import Manifest, Target +from providers.xiaohongshu.provider import XiaohongshuProvider + + +@pytest.fixture +def img_manifest(tmp_path): + img1 = tmp_path / "01.png" + img2 = tmp_path / "02.png" + img1.write_bytes(b"png1") + img2.write_bytes(b"png2") + return Manifest( + schema_version="0.2", + type="image-post", + title="短标题", + body="这是一段不超过 1000 字的图文 caption。", + mode="dry-run", + targets=[Target(name="xiaohongshu")], + images=[str(img1), str(img2)], + tags=["AI", "创业"], + ) + + +def test_validate_passes(img_manifest): + p = XiaohongshuProvider() + res = p.validate(img_manifest, img_manifest.targets[0]) + assert all(v.severity.value != "error" for v in res.violations) + + +def test_validate_title_too_long(img_manifest): + img_manifest.title = "这个标题肯定超过了二十个字符的小红书限制确实如此非常长" + p = XiaohongshuProvider() + res = p.validate(img_manifest, img_manifest.targets[0]) + assert any(v.code == "TITLE_TOO_LONG" for v in res.violations) + + +def test_validate_no_images(img_manifest): + img_manifest.images = [] + p = XiaohongshuProvider() + res = p.validate(img_manifest, img_manifest.targets[0]) + assert any(v.code == "IMAGE_COUNT_BELOW_MIN" for v in res.violations) + + +def test_prepare_writes_payload(img_manifest, tmp_path): + run_dir = tmp_path / "run" + run_dir.mkdir() + p = XiaohongshuProvider() + out = p.prepare(img_manifest, img_manifest.targets[0], run_dir) + assert out.payload_path.exists() + payload = json.loads(out.payload_path.read_text()) + assert payload["title"] == "短标题" + assert len(payload["images"]) == 2 + assert payload["tags"] == ["AI", "创业"] + + +def test_execute_dry_run(img_manifest, tmp_path): + run_dir = tmp_path / "run" + (run_dir / "packs" / "xiaohongshu").mkdir(parents=True) + p = XiaohongshuProvider() + p.prepare(img_manifest, img_manifest.targets[0], run_dir) + res = p.execute(run_dir, img_manifest.targets[0], mode="dry-run", credentials={}) + assert res.status == "ok" + assert res.mode_actual == "dry-run" + + +def test_execute_draft_invokes_local_script(img_manifest, tmp_path): + run_dir = tmp_path / "run" + (run_dir / "packs" / "xiaohongshu").mkdir(parents=True) + p = XiaohongshuProvider() + p.prepare(img_manifest, img_manifest.targets[0], run_dir) + + with patch( + "providers.xiaohongshu.provider._invoke_local_draft", + return_value={"draft_id": "20260506-foo", "draft_path": "/tmp/foo.json"}, + ) as mock: + # No credentials needed for local-draft mode in v0.2. + res = p.execute( + run_dir, + img_manifest.targets[0], + mode="draft", + credentials={}, + ) + mock.assert_called_once() + assert res.status == "ok" + assert res.mode_actual == "draft-local" + assert res.external_id == "20260506-foo" + assert res.extras["draft_path"] == "/tmp/foo.json" + + +def test_execute_draft_passes_payload_dict(img_manifest, tmp_path): + """_invoke_local_draft receives the loaded payload dict, not a path.""" + run_dir = tmp_path / "run" + (run_dir / "packs" / "xiaohongshu").mkdir(parents=True) + p = XiaohongshuProvider() + p.prepare(img_manifest, img_manifest.targets[0], run_dir) + + with patch( + "providers.xiaohongshu.provider._invoke_local_draft", + return_value={"draft_id": "x", "draft_path": "/tmp/x.json"}, + ) as mock: + p.execute(run_dir, img_manifest.targets[0], mode="draft", credentials={}) + + args, _ = mock.call_args + payload_arg = args[0] + assert isinstance(payload_arg, dict) + assert payload_arg["title"] == "短标题" + assert payload_arg["caption"] == "这是一段不超过 1000 字的图文 caption。" + + +def test_build_xhs_payload_reshapes_for_draft_sh(): + """Internal: payload reshape matches draft.sh's expected JSON shape.""" + from providers.xiaohongshu.provider import _build_xhs_payload + + out = _build_xhs_payload( + { + "title": "T", + "caption": "C", + "images": ["/abs/a.png"], + "tags": ["x"], + "extra_unused": "ignored", + } + ) + assert out == { + "title": "T", + "content": "C", # caption -> content + "images": ["/abs/a.png"], + "tags": ["x"], + } + + +def test_execute_publish_refused(img_manifest, tmp_path): + run_dir = tmp_path / "run" + (run_dir / "packs" / "xiaohongshu").mkdir(parents=True) + p = XiaohongshuProvider() + p.prepare(img_manifest, img_manifest.targets[0], run_dir) + with pytest.raises(NotImplementedError, match="publish"): + p.execute( + run_dir, + img_manifest.targets[0], + mode="publish", + credentials={}, + ) diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..dd182a9 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,47 @@ +[project] +name = "multi-media-publisher" +version = "0.2.0" +description = "Cross-platform content publishing orchestration." +requires-python = ">=3.10" +dependencies = [ + "pyyaml>=6.0", + "pyrage>=1.1", + "tomli_w>=1.0", + "tomli>=2.0; python_version < '3.11'", +] + +[project.scripts] +mmp = "core.cli:main" + +[project.optional-dependencies] +dev = [ + "pytest>=7.4", + "pytest-cov>=4.1", + "ruff>=0.4", + "mypy>=1.8", + "types-PyYAML", +] + +[tool.setuptools.packages.find] +include = ["core*", "providers*"] + +[tool.ruff] +line-length = 100 +target-version = "py310" + +[tool.ruff.lint] +select = ["E", "F", "I", "B", "UP"] +ignore = ["E501"] + +[tool.mypy] +python_version = "3.10" +strict = false +warn_unused_ignores = true +warn_redundant_casts = true +disallow_untyped_defs = true +files = ["core"] + +[tool.pytest.ini_options] +testpaths = ["tests", "providers"] +python_files = ["test_*.py"] +addopts = "-q --strict-markers" diff --git a/references/image-post-mvp.md b/references/image-post-mvp.md deleted file mode 100644 index 2e1cd16..0000000 --- a/references/image-post-mvp.md +++ /dev/null @@ -1,113 +0,0 @@ -# Image Post MVP - -Targets: `xiaohongshu`, `wechat-image`. - -## Installed Dependencies - -- `xiaohongshu` — local MCP-backed skill. Prefer its draft/save-to-platform-draft flow. -- `multi-post` — installed from ClawHub. Browser automation reference for multi-platform text+images. Includes: - - `references/platform-rules.md` - - `references/platform-flows.md` -- `social-media-publish` — installed from ClawHub. Browser automation instructions for 微信公众号、百家号、小红书. Use as WeChat 图文 browser-flow reference. - -## Unified Input - -Use an `image-post` manifest: - -```yaml -type: image-post -title: "标题" -body: ./caption.md -mode: draft -targets: [xiaohongshu, wechat-image] -assets: - images: - - ./01.png -tags: [AI, 创业] -``` - -## Phase 2 Audit Summary - -- `multi-post` is instruction-only and useful for platform rules/browser flows, but it is direct-publish oriented; MMP must wrap it with draft/confirmation safety. -- `social-media-publish` is instruction-only and better aligned with draft-first WeChat flows; use it for WeChat/Baijiahao procedure. -- See `references/phase2-audit.md` for detailed notes. - -## Preparation Script - -Use `scripts/prepare_image_post.py ` to create an offline run pack. It validates local images, resolves body text, writes per-target `payload.json`, and creates `preview.md`. It never publishes. - -## Adapter Outputs - -Generate a platform pack directory per target: - -```text -packs/ - xiaohongshu/ - content.md - payload.json - wechat-image/ - content.md - payload.json -``` - -### Xiaohongshu Payload - -```json -{ - "title": "<=20 chars preferred", - "content": "<=1000 chars preferred", - "images": ["/absolute/path/01.png"], - "tags": ["AI", "创业"] -} -``` - -Execution preference: - -1. `skills/xiaohongshu/scripts/draft.sh ` to create a local draft. -2. `skills/xiaohongshu/scripts/save-platform-draft.sh latest` only after user confirms external draft write. -3. `skills/xiaohongshu/scripts/publish-draft.sh latest --yes` only after explicit public publish confirmation. - -### WeChat Image Payload - -```json -{ - "title": "title", - "content": "caption/body", - "images": ["/absolute/path/01.png"], - "cover": "/absolute/path/01.png", - "mode": "draft" -} -``` - -Execution preference: - -1. Use `social-media-publish` browser workflow for WeChat 图文/公众号-like browser drafting after verifying the exact UI. -2. Use `multi-post` browser flow patterns for upload/confirmation handling. -3. Stop at draft/save screen unless the user explicitly confirms public publish. - -## Draft Executor - -Use `scripts/execute_image_post.py ` only after reviewing `preview.md`. Current behavior: - -- `--target xiaohongshu --yes-draft` creates a local Xiaohongshu draft through `skills/xiaohongshu/scripts/draft.sh`. -- `--target wechat-image` writes a `browser-flow.md` guide for manual/agent browser drafting. -- Public publish is intentionally unsupported. - -## Preflight Checklist - -- Confirm target list. -- Confirm mode is `draft` unless user explicitly requests publish. -- Verify every image path exists and is local. -- Resolve body path to text. -- Generate platform packs and show concise preview. -- Ask for confirmation before any browser/API write. - -## Notes - -- `social-media-publish` currently describes 微信公众号草稿流程, not a code adapter. Treat it as procedural guidance. -- The user distinguishes 微信图文内容 from 公众号文章. If the actual UI path differs, update this reference after first manual/browser run. - - -## WeChat Calibration Status - -Initial browser calibration is blocked by navigation policy for `mp.weixin.qq.com`. See `references/wechat-image-calibration.md`. diff --git a/references/manifest-schema.md b/references/manifest-schema.md deleted file mode 100644 index aa5e14a..0000000 --- a/references/manifest-schema.md +++ /dev/null @@ -1,75 +0,0 @@ -# Manifest Schema - -Use YAML. Keep one source content package and a list of targets. - -## Shared Fields - -```yaml -type: image-post | longform | video-post -title: "Human-readable title" -body: "Inline content or ./path/to/content.md" -summary: "Optional short synopsis" -mode: draft # draft | publish -language: zh-CN -targets: - - xiaohongshu -assets: - cover: ./cover.png - images: [] - video: null -tags: [] -cta: "Optional call to action" -metadata: - slug: optional-slug - source: optional-source -``` - -## Image Post Example - -```yaml -type: image-post -title: "20字内小红书标题,可另行适配" -body: ./caption.md -mode: draft -targets: - - xiaohongshu - - wechat-image -assets: - images: - - ./01.png - - ./02.png -tags: - - AI - - 创业 -``` - -## Longform Example - -```yaml -type: longform -title: "长文章标题" -body: ./article.md -mode: draft -targets: - - wechat-article - - x-article - - substack -assets: - cover: ./cover.png -tags: - - AI - - 观察 -``` - -## Generated Payload Notes - -- `prepare_image_post.py` writes selected target payloads under `packs//payload.json` and, when `wechat-image` is selected, also writes `packs/wechat-article-api-bridge/payload.json`. The bridge payload contains `title`, `content`, `cover`, `digest`, `tags`, and `mode`, and can be passed to `wechat_api_draft.py draft-from-payload --dry-run` without extra field edits. -- `prepare_longform.py` writes `packs/wechat-article/payload.json`, `packs/x-article/payload.json`, and `packs/substack/payload.json` for the MVP longform targets. The WeChat article payload includes both Markdown `content` and minimal rendered `html` for API draft smoke tests. - -## Validation Rules - -- `type`, `title`, `body`, `targets`, and `mode` are required. -- `image-post` requires at least one image unless the target explicitly supports text-only. -- `longform` should use a Markdown body path when possible. -- `publish` mode requires explicit user confirmation before any public action. -- Paths are resolved relative to the manifest file. diff --git a/references/phase2-audit.md b/references/phase2-audit.md deleted file mode 100644 index 27298e7..0000000 --- a/references/phase2-audit.md +++ /dev/null @@ -1,89 +0,0 @@ -# Phase 2 Audit: multi-post + social-media-publish - -## multi-post - -- Install slug: `multi-post` -- Owner/version: `jeffchang2024` / `1.0.0` -- Structure: - - `SKILL.md` - - `references/platform-flows.md` - - `references/platform-rules.md` -- Code/scripts: none; instruction-only skill. -- Security posture: clean scan, but it uses the user's logged-in Chrome profile and can post as the current account. It does not enforce per-platform confirmation by itself. - -### Dependencies - -- OpenClaw browser automation with user Chrome/profile. -- Target platforms logged in already. -- Image files must be local and browser-accessible. - -### Supported Platforms - -Weibo, Xiaohongshu, Zhihu, Twitter/X, Reddit, V2EX, LinkedIn, Douban. - -### Reusable Parts - -- Platform adaptation rules: char limits, image counts, hashtags, tone, link handling. -- Browser flow templates per platform. -- Post-publish logging checklist: screenshot, URL, status, timestamp, small waits between platforms. - -### MMP Integration Notes - -Use `multi-post` primarily as: - -1. Content adaptation reference. -2. Browser automation flow reference. -3. Optional executor for non-core social platforms after adding MMP's confirmation wrapper. - -Do not let it drive all-platform direct publish by default. - -## social-media-publish - -- Install slug: `social-media-publish` -- Owner/version: `lsmonet` / `1.0.0` -- Structure: - - `SKILL.md` -- Code/scripts: none; instruction-only skill. -- Security posture: clean scan, but it can operate logged-in accounts for drafts/publishing/group-send. It explicitly expects final user confirmation. - -### Dependencies - -- OpenClaw browser automation against web UIs. -- First-time manual login. -- No special config. - -### Supported Platforms - -- WeChat Official Account / 微信公众号 -- Baijiahao / 百度百家号 -- Xiaohongshu via another publish skill path, not expanded here. - -### Inputs - -- WeChat Official Account: title required, body, optional cover image, Markdown conversion/formatting. -- Baijiahao: title required, body, cover image required, category. - -### Reusable Parts - -- WeChat flow: backend → content/image-text → new creation → fill title/body/cover → save draft or publish. -- Baijiahao flow: backend → publish image-text → fill fields → submit for review. -- Interaction protocol: confirm platform, title, body, cover before automation. - -### MMP Integration Notes - -Use `social-media-publish` as the safer browser-flow reference for WeChat/Baijiahao, especially because it recommends draft-first for WeChat. - -## Phase 2 Decision - -- Use `multi-post` for platform rules and generic browser flow patterns. -- Use `social-media-publish` for WeChat/Baijiahao-specific flow and confirmation protocol. -- Keep MMP as the safety wrapper: - 1. Generate platform previews. - 2. Show target/account/page/title/body/images/mode. - 3. Ask for confirmation before external write. - 4. Execute sequentially, never parallel. - 5. Log screenshot/URL/status/error. - -## Extra Audit Needed - -If MMP later supports 小红书长文 through a separate `xiaohongshu-publish` skill, audit that skill independently before wiring it in. diff --git a/references/platform-map.md b/references/platform-map.md deleted file mode 100644 index 0259700..0000000 --- a/references/platform-map.md +++ /dev/null @@ -1,25 +0,0 @@ -# Platform Map - -## Image Post / 图文内容 - -| Target | Meaning | Preferred integration | Notes | -|---|---|---|---| -| `xiaohongshu` | 小红书图文笔记 | local `xiaohongshu` skill | Supports local draft, platform draft, and publish; title <=20 chars, content <=1000 chars in current MCP docs. | -| `wechat-image` | 微信图文内容 / 微信图文 feed-style post | `lsmonet/social-media-publish` / `social-media-publish` | User confirmed this is the right category for WeChat 图文内容. Verify installed skill before first use. | - -## Longform / 长文章 - -| Target | Meaning | Preferred integration | Notes | -|---|---|---|---| -| `wechat-article` | 微信公众号文章 | WeChat Official Account API provider, `wenyan`, `wenyan-publish`, `wechat-publisher` | Prefer API draft creation when AppID/AppSecret/API permission are available; browser is fallback. | -| `x-article` | X/Twitter Articles | `x-articles` | Distinguish from tweets/threads. Browser automation likely; confirm logged-in account. | -| `x-thread` | Twitter/X thread | `twitter-post`, `x-twitter-poster`, `tweet-cli` | Use only when user asks for thread/short social copy. | -| `substack` | Substack post/newsletter | `substack-autopilot` or verified generic Substack publisher | Existing `substack` result may be account-specific; treat as unverified for Lewis until inspected. | - -## Future Video - -| Target | Meaning | Candidate integration | Notes | -|---|---|---|---| -| `xiaohongshu-video` | 小红书视频 | local `xiaohongshu` skill | Local skill exposes `publish_with_video`. | -| `wechat-channel` | 视频号 | `video-multi-publish`, `auto-publisher`, or browser automation | Not in MVP. | -| `douyin`, `bilibili`, `youtube-shorts` | Video distribution | `video-multi-publish`, `auto-publisher` | Not in MVP. | diff --git a/references/workflows.md b/references/workflows.md deleted file mode 100644 index 439e841..0000000 --- a/references/workflows.md +++ /dev/null @@ -1,45 +0,0 @@ -# Workflows - -## Phase 1 — Planning + Skeleton - -- Create skill structure and manifest schema. -- Record platform matrix and candidate integrations. -- Provide validation/adaptation scripts. -- No real external publishing. - -## Phase 2 — Image Post MVP - -Targets: `xiaohongshu`, `wechat-image`. - -1. Accept content package: title, caption/body, images, tags. -2. Run `scripts/prepare_image_post.py ` to generate `preview.md` and per-target payloads. -3. Review preview with the user. -4. After confirmation, use `scripts/execute_image_post.py --target ... --yes-draft` for supported draft actions. - - Xiaohongshu: local draft via `xiaohongshu/scripts/draft.sh`. - - WeChat image: generate `browser-flow.md`; execute browser flow only after UI calibration. -5. Record draft status and links/screenshots if available. - -## Phase 3 — Longform MVP - -Targets: `wechat-article`, `x-article`, `substack`. - -1. Accept Markdown article and optional cover. -2. Run `scripts/prepare_longform.py ` to generate `preview.md` and per-target payloads. -3. Review the preview and payloads with the user. -4. For WeChat API smoke tests, run `scripts/wechat_api_draft.py draft-from-payload /packs/wechat-article/payload.json --dry-run`. -5. Create real external drafts only after confirmation and connector/account verification. -6. Ask again before public publish/newsletter send. - -## Phase 4 — Video Extension - -Add `video-post` target adapters only after choosing a video publisher skill and validating login/account flow. - -## Operational Checklist - -- Confirm target list. -- Confirm mode (`draft` vs `publish`). -- Validate required assets exist. -- Preview platform-specific adaptations. -- Ask for approval before external write. -- Dispatch target-by-target. -- Log result. diff --git a/scripts/adapt_content.py b/scripts/adapt_content.py index 030879d..ee7208d 100755 --- a/scripts/adapt_content.py +++ b/scripts/adapt_content.py @@ -1,127 +1,27 @@ -#!/usr/bin/env python3 -"""Create lightweight per-platform content pack scaffolds from a manifest.""" -from __future__ import annotations - -import argparse -import json -import pathlib -import textwrap - -try: - import yaml # type: ignore -except Exception: - yaml = None - - -def simple_yaml_load(text: str) -> dict: - """Tiny fallback parser for the simple manifests in this skill.""" - data: dict = {} - stack: list[tuple[int, object]] = [(-1, data)] - last_key_at_indent: dict[int, str] = {} - for raw in text.splitlines(): - if not raw.strip() or raw.lstrip().startswith("#"): - continue - indent = len(raw) - len(raw.lstrip(" ")) - line = raw.strip() - while stack and indent <= stack[-1][0]: - stack.pop() - parent = stack[-1][1] - if line.startswith("- "): - item = line[2:].strip().strip('"\'') - if isinstance(parent, list): - parent.append(item) - continue - if ":" not in line: - continue - key, val = line.split(":", 1) - key = key.strip() - val = val.strip() - if val == "": - # Heuristic: common list containers vs mapping containers. - container = [] if key in {"targets", "images", "tags"} else {} - if isinstance(parent, dict): - parent[key] = container - stack.append((indent, container)) - last_key_at_indent[indent] = key - else: - val = val.split(" #", 1)[0].strip().strip('"\'') - if isinstance(parent, dict): - parent[key] = val - return data - - -def yaml_load(text: str) -> dict: - if yaml: - return yaml.safe_load(text) - return simple_yaml_load(text) - +"""DEPRECATED in v0.2. Use `mmp publish `. -def resolve_path(base: pathlib.Path, value: str) -> pathlib.Path: - p = pathlib.Path(value) - return p if p.is_absolute() else (base / p).resolve() - - -def read_body(base: pathlib.Path, body: str) -> str: - p = resolve_path(base, body) - if p.exists(): - return p.read_text(encoding="utf-8") - return body - - -def existing_images(base: pathlib.Path, data: dict) -> list[str]: - assets = data.get("assets") if isinstance(data.get("assets"), dict) else {} - images = assets.get("images") or [] - out = [] - for img in images: - p = resolve_path(base, str(img)) - out.append(str(p)) - return out +Logic moved into core/runner.py + per-provider rules. +This shim will be removed in v0.3. +""" +from __future__ import annotations -def payload_for(target: str, data: dict, body: str, base: pathlib.Path) -> dict: - title = str(data.get("title", "")) - images = existing_images(base, data) - tags = data.get("tags", []) or [] - if target == "xiaohongshu": - return {"title": title[:20], "content": body[:1000], "images": images, "tags": tags} - if target == "wechat-image": - cover = images[0] if images else ((data.get("assets") or {}).get("cover") if isinstance(data.get("assets"), dict) else None) - return {"title": title, "content": body, "images": images, "cover": cover, "mode": data.get("mode", "draft")} - return {"title": title, "content": body, "images": images, "tags": tags, "mode": data.get("mode", "draft")} +import sys +import warnings def main() -> int: - ap = argparse.ArgumentParser() - ap.add_argument("manifest", type=pathlib.Path) - ap.add_argument("--out", type=pathlib.Path, required=True) - args = ap.parse_args() - data = yaml_load(args.manifest.read_text(encoding="utf-8")) - base = args.manifest.parent - body = read_body(base, str(data.get("body", ""))) - args.out.mkdir(parents=True, exist_ok=True) - - for target in data.get("targets", []): - target_dir = args.out / str(target) - target_dir.mkdir(parents=True, exist_ok=True) - title = str(data.get("title", "")) - adapted_title = title[:20] if target == "xiaohongshu" else title - md = textwrap.dedent(f"""\ - --- - target: {target} - source_type: {data.get('type')} - mode: {data.get('mode')} - title: {adapted_title!r} - tags: {json.dumps(data.get('tags', []), ensure_ascii=False)} - --- - - {body} - """) - (target_dir / "content.md").write_text(md, encoding="utf-8") - (target_dir / "payload.json").write_text(json.dumps(payload_for(str(target), data, body, base), ensure_ascii=False, indent=2), encoding="utf-8") - - print(json.dumps({"ok": True, "out": str(args.out), "targets": data.get("targets", [])}, ensure_ascii=False, indent=2)) - return 0 + warnings.warn( + "adapt_content.py is deprecated; use `python3 scripts/mmp.py publish `", + DeprecationWarning, + stacklevel=2, + ) + print( + "DEPRECATED. Run `python3 scripts/mmp.py publish ` instead.", + file=sys.stderr, + ) + return 1 if __name__ == "__main__": - raise SystemExit(main()) + sys.exit(main()) diff --git a/scripts/execute_image_post.py b/scripts/execute_image_post.py index 65510ae..d75d1e4 100755 --- a/scripts/execute_image_post.py +++ b/scripts/execute_image_post.py @@ -1,149 +1,28 @@ -#!/usr/bin/env python3 -"""Execute prepared image-post run actions safely. +"""DEPRECATED in v0.2. Use `mmp publish --mode-override draft`. -Supported now: -- xiaohongshu local draft creation via local xiaohongshu skill script. -- wechat-image instruction bundle generation only; browser execution remains manual/agent-guided. - -This script never performs public publish. It refuses publish mode unless a future -executor implements an explicit confirmation token. +Logic moved to providers/xiaohongshu/ and providers/wechat_image/. +This shim will be removed in v0.3. """ + from __future__ import annotations -import argparse -import datetime as dt -import json -import os -import pathlib -import subprocess import sys -from typing import Any - -ROOT = pathlib.Path(__file__).resolve().parents[1] -WORKSPACE = ROOT.parents[1] -XHS_DRAFT = pathlib.Path(os.environ.get("MMP_XHS_DRAFT_SH", str(WORKSPACE / "skills" / "xiaohongshu" / "scripts" / "draft.sh"))) - - -def load_json(path: pathlib.Path) -> dict[str, Any]: - return json.loads(path.read_text(encoding="utf-8")) - - -def write_json(path: pathlib.Path, data: dict[str, Any]) -> None: - path.write_text(json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8") - - -def append_result(run_dir: pathlib.Path, item: dict[str, Any]) -> None: - result_path = run_dir / "result.json" - data = load_json(result_path) if result_path.exists() else {"status": "started", "results": []} - data.setdefault("results", []).append(item) - if item.get("status") == "error": - data["status"] = "partial_error" - elif data.get("status") not in {"partial_error", "blocked"}: - data["status"] = "executed" - write_json(result_path, data) - - -def target_payload(run_dir: pathlib.Path, target: str) -> tuple[pathlib.Path, dict[str, Any]]: - path = run_dir / "packs" / target / "payload.json" - if not path.exists(): - raise SystemExit(f"payload not found for target {target}: {path}") - return path, load_json(path) - - -def ensure_draft_mode(payload: dict[str, Any], allow_external: bool) -> None: - mode = payload.get("mode", "draft") - if mode != "draft": - raise SystemExit("execute_image_post.py currently supports draft mode only; public publish is intentionally blocked") - if not allow_external: - raise SystemExit("external draft creation requires --yes-draft after user confirmation") - - -def xiaohongshu_local_draft(run_dir: pathlib.Path, payload_path: pathlib.Path, payload: dict[str, Any], yes_draft: bool) -> int: - ensure_draft_mode(payload, yes_draft) - if not XHS_DRAFT.exists(): - append_result(run_dir, {"target": "xiaohongshu", "status": "error", "action": "local-draft", "error": f"missing script: {XHS_DRAFT}", "timestamp": now()}) - return 2 - cmd = [str(XHS_DRAFT), json.dumps({k: payload[k] for k in ["title", "content", "images", "tags"] if k in payload}, ensure_ascii=False)] - proc = subprocess.run(cmd, cwd=str(XHS_DRAFT.parent), text=True, capture_output=True) - item = { - "target": "xiaohongshu", - "status": "ok" if proc.returncode == 0 else "error", - "action": "local-draft", - "command": "skills/xiaohongshu/scripts/draft.sh ", - "payload": str(payload_path), - "timestamp": now(), - "returncode": proc.returncode, - "stdout": proc.stdout[-4000:], - "stderr": proc.stderr[-4000:], - } - append_result(run_dir, item) - return proc.returncode - - -def wechat_instruction(run_dir: pathlib.Path, payload_path: pathlib.Path, payload: dict[str, Any]) -> int: - out = run_dir / "packs" / "wechat-image" / "browser-flow.md" - images = "\n".join(f"- {p}" for p in payload.get("images") or []) or "- (none)" - out.write_text(f"""# WeChat Image Draft Browser Flow - -This is a prepared execution guide. Do not public-publish without explicit confirmation. - -## Payload - -- Title: {payload.get('title', '')} -- Mode: {payload.get('mode', 'draft')} -- Cover: {payload.get('cover') or '(none)'} -- Payload file: {payload_path} - -## Images - -{images} - -## Content - -{payload.get('content', '')} - -## Draft-first Steps - -1. Open the relevant WeChat publishing backend/UI while logged into the correct account. -2. Navigate to the image/text content creation page for 微信图文内容. -3. Fill title and content from this payload. -4. Upload images in order; use the first image as cover when the UI requires one. -5. Stop at preview/save-draft. Do not group-send or public publish. -6. Record screenshot, draft URL/status, and any UI mismatch in `result.json`. - -## Calibration Note - -The first real run must verify whether the target is 微信图文内容 feed-style publishing or 微信公众号文章 drafting. Update `references/image-post-mvp.md` after calibration. -""", encoding="utf-8") - append_result(run_dir, {"target": "wechat-image", "status": "prepared", "action": "browser-flow-guide", "guide": str(out), "payload": str(payload_path), "timestamp": now()}) - return 0 - - -def now() -> str: - return dt.datetime.now().astimezone().isoformat(timespec="seconds") +import warnings def main() -> int: - ap = argparse.ArgumentParser() - ap.add_argument("run_dir", type=pathlib.Path, help="Run directory generated by prepare_image_post.py") - ap.add_argument("--target", choices=["xiaohongshu", "wechat-image", "all"], default="all") - ap.add_argument("--yes-draft", action="store_true", help="User confirmed external draft creation. Required for Xiaohongshu local draft creation.") - args = ap.parse_args() - - run_dir = args.run_dir.resolve() - if not run_dir.exists(): - raise SystemExit(f"run_dir not found: {run_dir}") - - targets = ["xiaohongshu", "wechat-image"] if args.target == "all" else [args.target] - rc = 0 - for target in targets: - payload_path, payload = target_payload(run_dir, target) - if target == "xiaohongshu": - rc = max(rc, xiaohongshu_local_draft(run_dir, payload_path, payload, args.yes_draft)) - elif target == "wechat-image": - rc = max(rc, wechat_instruction(run_dir, payload_path, payload)) - return rc + warnings.warn( + "execute_image_post.py is deprecated; use " + "`python3 scripts/mmp.py publish --mode-override draft`", + DeprecationWarning, + stacklevel=2, + ) + print( + "DEPRECATED. Run `python3 scripts/mmp.py publish --mode-override draft` instead.", + file=sys.stderr, + ) + return 1 if __name__ == "__main__": - raise SystemExit(main()) + sys.exit(main()) diff --git a/scripts/mmp.py b/scripts/mmp.py new file mode 100755 index 0000000..e67a53f --- /dev/null +++ b/scripts/mmp.py @@ -0,0 +1,355 @@ +#!/usr/bin/env python3 +"""multi-media-publisher unified CLI entry. + +Subcommands: validate, publish, setup, list, resume, doctor, wizard. +The wizard subcommand is implemented in Plan 2. +""" + +from __future__ import annotations + +import argparse +import json +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parent.parent +sys.path.insert(0, str(ROOT)) + + +def _build_parser() -> argparse.ArgumentParser: + p = argparse.ArgumentParser(prog="mmp", description="multi-media-publisher CLI") + sub = p.add_subparsers(dest="cmd", required=True) + + sub_validate = sub.add_parser("validate", help="Validate a manifest without executing") + sub_validate.add_argument("manifest", help="Path to manifest.yaml") + + sub_publish = sub.add_parser("publish", help="Run prepare+execute for a manifest") + sub_publish.add_argument("manifest", help="Path to manifest.yaml") + sub_publish.add_argument( + "--mode-override", + choices=["dry-run", "draft", "publish"], + default=None, + help="Override manifest top-level mode (CAUTION with publish)", + ) + + sub_setup = sub.add_parser("setup", help="Configure credentials for a provider") + sub_setup.add_argument("provider", help="Provider name (e.g. wechat-article)") + sub_setup.add_argument("--account", default="default") + + sub_list = sub.add_parser("list", help="List providers / accounts / runs") + sub_list.add_argument("kind", choices=["providers", "accounts", "runs"]) + + sub_resume = sub.add_parser("resume", help="Resume a previously failed run") + sub_resume.add_argument("run_dir") + sub_resume.add_argument("--target", default=None) + + sub.add_parser("doctor", help="Self-check: vault, providers, health") + + sub_wizard = sub.add_parser("wizard", help="Conversational manifest wizard") + sub_wizard.add_argument("--type", choices=["image-post", "longform", "video-post"]) + sub_wizard.add_argument("--targets", default=None, help="Comma-separated target names") + sub_wizard.add_argument( + "--dump-context", + action="store_true", + help="Dump current context as JSON for Claude to read", + ) + sub_wizard.add_argument( + "--commit", + metavar="MANIFEST_PATH", + default=None, + help="Validate a manifest YAML and persist as a new run dir", + ) + + return p + + +def cmd_validate(args: argparse.Namespace) -> int: + from core.errors import MMPError + from core.manifest import load_manifest + from core.provider import ProviderRegistry + from core.rules import Severity, Violation + + try: + m = load_manifest(args.manifest) + reg = ProviderRegistry() + reg.discover() + all_violations = [] + for t in m.targets: + provider = reg.resolve(t.name) + # Capability gate: target.mode must be supported by provider + cap_key = "publish" if t.mode == "publish" else ("draft" if t.mode == "draft" else None) + if cap_key and not provider.capabilities.get(cap_key, False): + all_violations.append( + Violation( + code="MODE_NOT_SUPPORTED", + message=( + f"provider does not support mode={t.mode} " + f"(caps={provider.capabilities})" + ), + severity=Severity.error, + target=t.name, + field_path="targets[].mode", + ) + ) + continue + res = provider.validate(m, t) + if res: + all_violations.extend(res.violations) + errs = [v for v in all_violations if v.severity.value == "error"] + warns = [v for v in all_violations if v.severity.value == "warning"] + for v in errs: + print(f"ERROR {v.target} {v.code} {v.message}", file=sys.stderr) + for v in warns: + print(f"WARN {v.target} {v.code} {v.message}", file=sys.stderr) + if errs: + return 2 + print(f"OK {len(m.targets)} targets validated.") + return 0 + except MMPError as e: + print(f"ERROR {e}", file=sys.stderr) + return 2 + + +def cmd_publish(args: argparse.Namespace) -> int: + from core.credentials import CredentialStore + from core.errors import MMPError + from core.manifest import load_manifest, write_lock + from core.provider import ProviderRegistry + from core.run import Run + + try: + m = load_manifest(args.manifest) + if args.mode_override: + m.mode = args.mode_override + for t in m.targets: + t.mode = args.mode_override + + reg = ProviderRegistry() + reg.discover() + store = CredentialStore() + + run = Run.create(title=m.title, mmp_version="0.2.0", host="cli", mode=m.mode) + # Write a self-contained manifest: inline the body so the run dir + # doesn't depend on the source dir for resume / forensics. + import yaml as _yaml + + src_yaml = _yaml.safe_load(Path(args.manifest).read_text(encoding="utf-8")) + src_yaml["body"] = m.body # inlined / loaded content + (run.dir / "manifest.yaml").write_text( + _yaml.safe_dump(src_yaml, allow_unicode=True, sort_keys=False), + encoding="utf-8", + ) + write_lock(m, run.dir) + + for t in m.targets: + run.log("TARGET_START", target=t.name, account=t.account) + try: + provider = reg.resolve(t.name) + cap_key = ( + "publish" if t.mode == "publish" else ("draft" if t.mode == "draft" else None) + ) + if cap_key and not provider.capabilities.get(cap_key, False): + run.add_target_result( + name=t.name, + account=t.account, + status="failed", + mode_actual="dry-run", + error=f"capability: provider does not support mode={t.mode}", + ) + run.log("CAPABILITY_FAIL", target=t.name, mode=t.mode) + continue + v_res = provider.validate(m, t) + errs = [ + v for v in (v_res.violations if v_res else []) if v.severity.value == "error" + ] + if errs: + run.add_target_result( + name=t.name, + account=t.account, + status="failed", + mode_actual="dry-run", + error=f"validation: {[v.code for v in errs]}", + violations=[v.__dict__ for v in errs], + ) + run.log("VALIDATE_FAIL", target=t.name, codes=[v.code for v in errs]) + continue + + provider.prepare(m, t, run.dir) + run.log("PREPARE_OK", target=t.name) + + creds: dict[str, str] = {} + if t.mode != "dry-run": + required = [c.key for c in provider.required_credentials] + if required: + # Only consult the vault when the provider actually needs creds. + # Providers with empty required_credentials (e.g. local-only flows) + # get an empty creds dict. + creds = store.get(t.name, t.account, required_keys=required) + + exec_res = provider.execute(run.dir, t, t.mode, creds) + run.add_target_result( + name=t.name, + account=t.account, + status=exec_res.status, + mode_actual=exec_res.mode_actual, + external_id=exec_res.external_id, + draft_url=exec_res.draft_url, + ) + run.log( + "EXECUTE_OK", + target=t.name, + mode_actual=exec_res.mode_actual, + external_id=exec_res.external_id, + ) + except Exception as exc: + run.add_target_result( + name=t.name, + account=t.account, + status="failed", + mode_actual=t.mode, + error=str(exc), + ) + run.log("TARGET_FAIL", target=t.name, error=str(exc)) + + run.finalize() + print(f"RUN_DIR {run.dir}") + return 0 + except MMPError as e: + print(f"ERROR {e}", file=sys.stderr) + return 2 + + +def cmd_setup(args: argparse.Namespace) -> int: + from core.credentials import CredentialStore + from core.provider import ProviderRegistry + + reg = ProviderRegistry() + reg.discover() + try: + provider = reg.resolve(args.provider) + except Exception as e: + print(f"ERROR {e}", file=sys.stderr) + return 2 + + store = CredentialStore() + values: dict[str, str] = {} + print( + f"Configure {args.provider} (account: {args.account}). " + "Press Enter to skip a key.\n" + "(For Claude-driven setup, see core/wizard/credential_setup.md.)" + ) + for spec in provider.required_credentials: + prompt = f" {spec.key}" + if spec.description: + prompt += f" ({spec.description})" + if spec.setup_hint: + prompt += f" hint: {spec.setup_hint}" + prompt += ": " + if spec.secret: + import getpass + + v = getpass.getpass(prompt) + else: + v = input(prompt) + if v: + values[spec.key] = v + if values: + store.set(args.provider, args.account, values) + print(f"OK saved {len(values)} keys to vault.") + else: + print("nothing to save.") + return 0 + + +def cmd_list(args: argparse.Namespace) -> int: + if args.kind == "providers": + from core.provider import ProviderRegistry + + reg = ProviderRegistry() + reg.discover() + for info in reg.list(): + caps = ",".join(k for k, v in info.capabilities.items() if v) + print(f" {info.name} ({info.source}) media={info.media_types} caps={caps}") + elif args.kind == "accounts": + from core.credentials import CredentialStore + + store = CredentialStore() + for acc in store.list_accounts(): + print(f" {acc}") + elif args.kind == "runs": + from core import host as h + + rd = h.runs_dir() + if rd.exists(): + for d in sorted(rd.iterdir()): + if d.is_dir(): + print(f" {d.name}") + return 0 + + +def cmd_resume(args: argparse.Namespace) -> int: + print("resume not implemented in Plan 1; coming soon.", file=sys.stderr) + return 1 + + +def cmd_doctor(args: argparse.Namespace) -> int: + from core import host as h + from core.credentials import CredentialStore + from core.provider import ProviderRegistry + + print(f"host: {h.detect_host()}") + print(f"vault: {h.vault_path()} exists={h.vault_path().exists()}") + reg = ProviderRegistry() + reg.discover() + print(f"providers: {len(reg.list())}") + store = CredentialStore() + print(f"accounts: {len(store.list_accounts())}") + return 0 + + +def cmd_wizard(args: argparse.Namespace) -> int: + if args.dump_context: + from core.wizard.context import build_context + + ctx = build_context(media_type=args.type) + print(json.dumps(ctx, indent=2, ensure_ascii=False)) + return 0 + if args.commit: + from core.errors import MMPError + from core.wizard.commit import commit_manifest + + try: + run_dir = commit_manifest(args.commit) + print(f"RUN_DIR {run_dir}") + return 0 + except MMPError as e: + print(f"ERROR {e}", file=sys.stderr) + return 2 + # interactive (no flags) — Claude is expected to drive via SKILL.md prompts + print( + "wizard interactive mode is driven by Claude reading core/wizard/*.md.\n" + "Run with --dump-context to fetch state, or --commit to persist a manifest.", + file=sys.stderr, + ) + return 1 + + +_DISPATCH = { + "validate": cmd_validate, + "publish": cmd_publish, + "setup": cmd_setup, + "list": cmd_list, + "resume": cmd_resume, + "doctor": cmd_doctor, + "wizard": cmd_wizard, +} + + +def main(argv: list[str] | None = None) -> int: + p = _build_parser() + args = p.parse_args(argv) + return _DISPATCH[args.cmd](args) + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/scripts/prepare_image_post.py b/scripts/prepare_image_post.py index 4fbd239..901c91f 100755 --- a/scripts/prepare_image_post.py +++ b/scripts/prepare_image_post.py @@ -1,279 +1,27 @@ -#!/usr/bin/env python3 -"""Prepare an image-post run pack for Xiaohongshu + WeChat image content. +"""DEPRECATED in v0.2. Use `mmp publish `. -This script is deliberately offline-only: it validates input, resolves local -paths, generates platform payloads and previews, and writes a run directory. -It never opens browsers and never publishes. +Logic moved to providers/xiaohongshu/ and providers/wechat_image/. +This shim will be removed in v0.3. """ + from __future__ import annotations -import argparse -import datetime as dt -import json -import pathlib -import re -import shutil import sys -import textwrap - -try: - import yaml # type: ignore -except Exception: - yaml = None - -IMAGE_EXTS = {".png", ".jpg", ".jpeg", ".webp", ".gif"} -DEFAULT_TARGETS = ["xiaohongshu", "wechat-image"] - - -def simple_yaml_load(text: str) -> dict: - data: dict = {} - stack: list[tuple[int, object]] = [(-1, data)] - for raw in text.splitlines(): - if not raw.strip() or raw.lstrip().startswith("#"): - continue - indent = len(raw) - len(raw.lstrip(" ")) - line = raw.strip() - while stack and indent <= stack[-1][0]: - stack.pop() - parent = stack[-1][1] - if line.startswith("- "): - if isinstance(parent, list): - parent.append(line[2:].strip().strip('"\'')) - continue - if ":" not in line: - continue - key, val = line.split(":", 1) - key, val = key.strip(), val.strip() - if not val: - container = [] if key in {"targets", "images", "tags"} else {} - if isinstance(parent, dict): - parent[key] = container - stack.append((indent, container)) - else: - val = val.split(" #", 1)[0].strip().strip('"\'') - if isinstance(parent, dict): - parent[key] = val - return data - - -def yaml_load(path: pathlib.Path) -> dict: - text = path.read_text(encoding="utf-8") - data = yaml.safe_load(text) if yaml else simple_yaml_load(text) - if not isinstance(data, dict): - raise SystemExit("Manifest must be a YAML mapping/object") - return data - - -def resolve(base: pathlib.Path, value: str) -> pathlib.Path: - p = pathlib.Path(value).expanduser() - return p if p.is_absolute() else (base / p).resolve() - - -def read_body(base: pathlib.Path, body: str) -> tuple[str, str | None]: - p = resolve(base, body) - if p.exists() and p.is_file(): - return p.read_text(encoding="utf-8"), str(p) - return body, None - - -def slugify(s: str) -> str: - s = re.sub(r"[^\w\u4e00-\u9fff-]+", "-", s.lower()).strip("-") - return s[:48] or "image-post" - - -def truncate_text(s: str, limit: int) -> str: - if len(s) <= limit: - return s - return s[: max(0, limit - 1)].rstrip() + "…" - - -def normalize_tag(tag: str) -> str: - return tag.strip().lstrip("#") - - -def validate_images(base: pathlib.Path, images: list) -> tuple[list[str], list[str]]: - resolved: list[str] = [] - errors: list[str] = [] - for raw in images: - p = resolve(base, str(raw)) - if not p.exists(): - errors.append(f"image not found: {p}") - continue - if p.suffix.lower() not in IMAGE_EXTS: - errors.append(f"unsupported image extension: {p}") - continue - resolved.append(str(p)) - return resolved, errors - - -def build_payloads(data: dict, body_text: str, images: list[str]) -> dict[str, dict]: - title = str(data.get("title", "")).strip() - tags = [normalize_tag(str(t)) for t in (data.get("tags") or []) if str(t).strip()] - cta = str(data.get("cta", "")).strip() - xhs_content = body_text.strip() - if tags: - xhs_content = xhs_content + "\n\n" + " ".join(f"#{t}" for t in tags) - if cta: - xhs_content = xhs_content + "\n\n" + cta - - return { - "xiaohongshu": { - "title": truncate_text(title, 20), - "content": truncate_text(xhs_content, 1000), - "images": images[:9], - "tags": tags, - "mode": data.get("mode", "draft"), - "notes": ["小红书标题按 20 字截断", "图片最多取前 9 张", "默认仅准备/草稿,不发布"], - }, - "wechat-image": { - "title": title, - "content": body_text.strip() + (("\n\n" + cta) if cta else ""), - "images": images, - "cover": images[0] if images else None, - "tags": tags, - "mode": data.get("mode", "draft"), - "notes": ["微信图文 UI 首次执行需人工校准", "默认保存草稿/等待确认,不群发"], - }, - # API-compatible bridge payload. This lets the same prepared image-post - # package be smoke-tested with wechat_api_draft.py dry-run when the - # operator chooses to treat 微信图文内容 as a 公众号草稿 fallback. - "wechat-article": { - "title": truncate_text(title, 64), - "content": body_text.strip() + (("\n\n" + cta) if cta else ""), - "cover": images[0] if images else None, - "tags": tags, - "mode": data.get("mode", "draft"), - "digest": truncate_text(re.sub(r"\s+", " ", body_text).strip(), 120), - "notes": ["由 image-post 生成的公众号 API 兼容 payload", "可用于 wechat_api_draft.py draft-from-payload --dry-run", "真实 API 草稿需要 thumb_media_id 或上传 cover"], - }, - } - - -def preview_markdown(data: dict, payloads: dict[str, dict], targets: list[str], source_body: str | None, errors: list[str]) -> str: - lines = [ - "# Image Post Preview", - "", - f"- Source title: {data.get('title', '')}", - f"- Source body: {source_body or 'inline'}", - f"- Mode: {data.get('mode', 'draft')}", - f"- Targets: {', '.join(targets)}", - "", - ] - if errors: - lines += ["## Validation Errors", ""] + [f"- {e}" for e in errors] + [""] - for target in targets: - payload = payloads[target] - lines += [ - f"## {target}", - "", - f"- Title: {payload.get('title', '')}", - f"- Images: {len(payload.get('images') or [])}", - f"- Tags: {', '.join(payload.get('tags') or []) or '(none)'}", - f"- Mode: {payload.get('mode')}", - "", - "### Content", - "", - str(payload.get("content", "")), - "", - "### Notes", - "", - ] + [f"- {n}" for n in payload.get("notes", [])] + [""] - lines += [ - "## Next Step", - "", - "Confirm before any external browser/API write. Recommended first action: create drafts only.", - ] - return "\n".join(lines) +import warnings def main() -> int: - ap = argparse.ArgumentParser() - ap.add_argument("manifest", type=pathlib.Path) - ap.add_argument("--runs-dir", type=pathlib.Path, default=pathlib.Path(__file__).resolve().parents[1] / "runs") - ap.add_argument("--copy-assets", action="store_true", help="Copy images into the run directory and use copied paths in payloads") - args = ap.parse_args() - - manifest = args.manifest.resolve() - data = yaml_load(manifest) - base = manifest.parent - errors: list[str] = [] - - if data.get("type") != "image-post": - errors.append("manifest type must be image-post") - if data.get("mode", "draft") not in {"draft", "publish"}: - errors.append("mode must be draft or publish") - targets = data.get("targets") or DEFAULT_TARGETS - targets = [str(t) for t in targets] - unsupported = [t for t in targets if t not in {"xiaohongshu", "wechat-image"}] - if unsupported: - errors.append(f"unsupported image-post targets for MVP: {', '.join(unsupported)}") - - body_text, source_body = read_body(base, str(data.get("body", ""))) - images_raw = ((data.get("assets") or {}).get("images") or []) if isinstance(data.get("assets"), dict) else [] - images, image_errors = validate_images(base, images_raw) - errors.extend(image_errors) - if not images: - errors.append("image-post requires at least one valid local image") - - ts = dt.datetime.now().strftime("%Y%m%d-%H%M%S") - run_dir = args.runs_dir / f"{ts}-{slugify(str(data.get('title', 'image-post')))}" - packs_dir = run_dir / "packs" - packs_dir.mkdir(parents=True, exist_ok=True) - - if args.copy_assets and images: - asset_dir = run_dir / "assets" - asset_dir.mkdir(exist_ok=True) - copied = [] - for i, src in enumerate(images, 1): - sp = pathlib.Path(src) - dst = asset_dir / f"{i:02d}{sp.suffix.lower()}" - shutil.copy2(sp, dst) - copied.append(str(dst.resolve())) - images = copied - - payloads = build_payloads(data, body_text, images) - selected_payloads = {t: payloads[t] for t in targets if t in payloads} - - (run_dir / "manifest.json").write_text(json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8") - for target, payload in selected_payloads.items(): - d = packs_dir / target - d.mkdir(exist_ok=True) - (d / "payload.json").write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8") - (d / "content.md").write_text(textwrap.dedent(f"""\ - --- - target: {target} - type: image-post - mode: {payload.get('mode')} - title: {json.dumps(payload.get('title', ''), ensure_ascii=False)} - --- - - {payload.get('content', '')} - """), encoding="utf-8") - - if "wechat-image" in selected_payloads: - bridge_dir = packs_dir / "wechat-article-api-bridge" - bridge_dir.mkdir(exist_ok=True) - bridge_payload = payloads["wechat-article"] - (bridge_dir / "payload.json").write_text(json.dumps(bridge_payload, ensure_ascii=False, indent=2), encoding="utf-8") - (bridge_dir / "content.md").write_text(textwrap.dedent(f"""\ - --- - target: wechat-article - source_target: wechat-image - type: image-post-api-bridge - mode: {bridge_payload.get('mode')} - title: {json.dumps(bridge_payload.get('title', ''), ensure_ascii=False)} - --- - - {bridge_payload.get('content', '')} - """), encoding="utf-8") - - preview = preview_markdown(data, payloads, targets, source_body, errors) - (run_dir / "preview.md").write_text(preview, encoding="utf-8") - (run_dir / "result.json").write_text(json.dumps({"status": "blocked" if errors else "prepared", "errors": errors, "targets": targets, "results": []}, ensure_ascii=False, indent=2), encoding="utf-8") - - print(json.dumps({"ok": not errors, "run_dir": str(run_dir), "preview": str(run_dir / "preview.md"), "targets": targets, "errors": errors}, ensure_ascii=False, indent=2)) - return 2 if errors else 0 + warnings.warn( + "prepare_image_post.py is deprecated; use `python3 scripts/mmp.py publish `", + DeprecationWarning, + stacklevel=2, + ) + print( + "DEPRECATED. Run `python3 scripts/mmp.py publish ` instead.", + file=sys.stderr, + ) + return 1 if __name__ == "__main__": - raise SystemExit(main()) + sys.exit(main()) diff --git a/scripts/prepare_longform.py b/scripts/prepare_longform.py index 009a52b..9cd1f29 100755 --- a/scripts/prepare_longform.py +++ b/scripts/prepare_longform.py @@ -1,289 +1,27 @@ -#!/usr/bin/env python3 -"""Prepare a longform run pack for WeChat Official Account + X Articles + Substack. +"""DEPRECATED in v0.2. Use `mmp publish `. -Offline-only by design: validates local inputs, resolves assets, generates per-target -payloads and previews, and writes a run directory. It never opens browsers, calls -external APIs, or publishes. +Logic moved to providers/wechat_article/, providers/x_article/, providers/substack/. +This shim will be removed in v0.3. """ + from __future__ import annotations -import argparse -import datetime as dt -import html -import json -import pathlib -import re -import shutil import sys -import textwrap -from typing import Any - -try: - import yaml # type: ignore -except Exception: - yaml = None - -IMAGE_EXTS = {".png", ".jpg", ".jpeg", ".webp", ".gif"} -DEFAULT_TARGETS = ["wechat-article", "x-article", "substack"] -SUPPORTED_TARGETS = set(DEFAULT_TARGETS) - - -def simple_yaml_load(text: str) -> dict[str, Any]: - data: dict[str, Any] = {} - stack: list[tuple[int, object]] = [(-1, data)] - for raw in text.splitlines(): - if not raw.strip() or raw.lstrip().startswith("#"): - continue - indent = len(raw) - len(raw.lstrip(" ")) - line = raw.strip() - while stack and indent <= stack[-1][0]: - stack.pop() - parent = stack[-1][1] - if line.startswith("- "): - if isinstance(parent, list): - parent.append(line[2:].strip().strip('"\'')) - continue - if ":" not in line: - continue - key, val = line.split(":", 1) - key, val = key.strip(), val.strip() - if not val: - container: object = [] if key in {"targets", "tags"} else {} - if isinstance(parent, dict): - parent[key] = container - stack.append((indent, container)) - else: - val = val.split(" #", 1)[0].strip().strip('"\'') - if isinstance(parent, dict): - parent[key] = val - return data - - -def yaml_load(path: pathlib.Path) -> dict[str, Any]: - text = path.read_text(encoding="utf-8") - data = yaml.safe_load(text) if yaml else simple_yaml_load(text) - if not isinstance(data, dict): - raise SystemExit("Manifest must be a YAML mapping/object") - return data - - -def resolve(base: pathlib.Path, value: str | pathlib.Path | None) -> pathlib.Path | None: - if value is None or str(value).strip() == "": - return None - p = pathlib.Path(str(value)).expanduser() - return p if p.is_absolute() else (base / p).resolve() - - -def read_body(base: pathlib.Path, body: str) -> tuple[str, str | None]: - p = resolve(base, body) - if p and p.exists() and p.is_file(): - return p.read_text(encoding="utf-8"), str(p) - return body, None - - -def slugify(s: str) -> str: - s = re.sub(r"[^\w\u4e00-\u9fff-]+", "-", s.lower()).strip("-") - return s[:48] or "longform" - - -def truncate_text(s: str, limit: int) -> str: - if len(s) <= limit: - return s - return s[: max(0, limit - 1)].rstrip() + "…" - - -def normalize_tag(tag: str) -> str: - return tag.strip().lstrip("#") - - -def markdown_to_html(text: str) -> str: - """Small deterministic Markdown subset for API draft previews. - - This intentionally avoids depending on a renderer. The payload also keeps the - original Markdown in `content` so downstream platform-specific renderers can - replace this HTML later. - """ - parts: list[str] = [] - for block in re.split(r"\n\s*\n", text.strip()): - block = block.strip() - if not block: - continue - if block.startswith("# "): - parts.append(f"

{html.escape(block[2:].strip())}

") - elif block.startswith("## "): - parts.append(f"

{html.escape(block[3:].strip())}

") - elif block.startswith("### "): - parts.append(f"

{html.escape(block[4:].strip())}

") - else: - parts.append("

" + html.escape(block).replace("\n", "
") + "

") - return "\n".join(parts) - - -def validate_cover(base: pathlib.Path, data: dict[str, Any]) -> tuple[str | None, list[str]]: - assets = data.get("assets") if isinstance(data.get("assets"), dict) else {} - cover_raw = assets.get("cover") if isinstance(assets, dict) else None - if not cover_raw: - return None, [] - p = resolve(base, str(cover_raw)) - if not p or not p.exists(): - return None, [f"cover image not found: {p}"] - if p.suffix.lower() not in IMAGE_EXTS: - return None, [f"unsupported cover image extension: {p}"] - return str(p), [] - - -def build_payloads(data: dict[str, Any], body_text: str, cover: str | None) -> dict[str, dict[str, Any]]: - title = str(data.get("title", "")).strip() - tags = [normalize_tag(str(t)) for t in (data.get("tags") or []) if str(t).strip()] - mode = str(data.get("mode", "draft")) - author = str(data.get("author") or (data.get("metadata") or {}).get("author") or "") if isinstance(data.get("metadata", {}), dict) else str(data.get("author") or "") - summary = str(data.get("summary") or "").strip() - digest = truncate_text(summary or re.sub(r"\s+", " ", re.sub(r"[#>*_`\-]", "", body_text)).strip(), 120) - source_url = str((data.get("metadata") or {}).get("source_url") or data.get("content_source_url") or "") if isinstance(data.get("metadata", {}), dict) else str(data.get("content_source_url") or "") - cta = str(data.get("cta") or "").strip() - article_body = body_text.strip() + (("\n\n" + cta) if cta else "") - - return { - "wechat-article": { - "title": truncate_text(title, 64), - "author": author, - "digest": digest, - "content": article_body, - "html": markdown_to_html(article_body), - "content_source_url": source_url, - "cover": cover, - "tags": tags, - "mode": mode, - "notes": ["微信公众号草稿标题按 64 字截断", "payload 可直接用于 wechat_api_draft.py draft-from-payload --dry-run", "真实 API 草稿需要 thumb_media_id 或上传 cover"], - }, - "x-article": { - "title": title, - "body": article_body, - "content": article_body, - "cover": cover, - "tags": tags, - "mode": mode, - "notes": ["X Articles 连接器未验证;默认只生成草稿 payload", "不要误用为短推文/thread"], - }, - "substack": { - "title": title, - "subtitle": summary, - "body": article_body, - "content": article_body, - "cover": cover, - "tags": tags, - "mode": mode, - "notes": ["Substack 连接器未验证;默认只生成草稿 payload", "发送 newsletter 前必须再次确认"], - }, - } - - -def preview_markdown(data: dict[str, Any], payloads: dict[str, dict[str, Any]], targets: list[str], source_body: str | None, errors: list[str]) -> str: - lines = [ - "# Longform Preview", - "", - f"- Source title: {data.get('title', '')}", - f"- Source body: {source_body or 'inline'}", - f"- Mode: {data.get('mode', 'draft')}", - f"- Targets: {', '.join(targets)}", - "", - ] - if errors: - lines += ["## Validation Errors", ""] + [f"- {e}" for e in errors] + [""] - for target in targets: - payload = payloads[target] - body = str(payload.get("body") or payload.get("content") or "") - lines += [ - f"## {target}", - "", - f"- Title: {payload.get('title', '')}", - f"- Cover: {payload.get('cover') or '(none)'}", - f"- Tags: {', '.join(payload.get('tags') or []) or '(none)'}", - f"- Mode: {payload.get('mode')}", - "", - "### Content excerpt", - "", - truncate_text(body, 1200), - "", - "### Notes", - "", - ] + [f"- {n}" for n in payload.get("notes", [])] + [""] - lines += ["## Next Step", "", "Review payloads, then confirm before any external draft/API/browser write."] - return "\n".join(lines) +import warnings def main() -> int: - ap = argparse.ArgumentParser() - ap.add_argument("manifest", type=pathlib.Path) - ap.add_argument("--runs-dir", type=pathlib.Path, default=pathlib.Path(__file__).resolve().parents[1] / "runs") - ap.add_argument("--copy-assets", action="store_true", help="Copy cover into the run directory and use copied path in payloads") - args = ap.parse_args() - - manifest = args.manifest.resolve() - data = yaml_load(manifest) - base = manifest.parent - errors: list[str] = [] - - if data.get("type") != "longform": - errors.append("manifest type must be longform") - if data.get("mode", "draft") not in {"draft", "publish"}: - errors.append("mode must be draft or publish") - if not str(data.get("title") or "").strip(): - errors.append("title is required") - - targets = data.get("targets") or DEFAULT_TARGETS - targets = [str(t) for t in targets] - unsupported = [t for t in targets if t not in SUPPORTED_TARGETS] - if unsupported: - errors.append(f"unsupported longform targets for MVP: {', '.join(unsupported)}") - - body_text, source_body = read_body(base, str(data.get("body", ""))) - if not body_text.strip(): - errors.append("longform requires non-empty body content") - - cover, cover_errors = validate_cover(base, data) - errors.extend(cover_errors) - - ts = dt.datetime.now().strftime("%Y%m%d-%H%M%S") - run_dir = args.runs_dir / f"{ts}-{slugify(str(data.get('title', 'longform')))}" - packs_dir = run_dir / "packs" - packs_dir.mkdir(parents=True, exist_ok=True) - - if args.copy_assets and cover: - asset_dir = run_dir / "assets" - asset_dir.mkdir(exist_ok=True) - src = pathlib.Path(cover) - dst = asset_dir / f"cover{src.suffix.lower()}" - shutil.copy2(src, dst) - cover = str(dst.resolve()) - - payloads = build_payloads(data, body_text, cover) - selected_payloads = {t: payloads[t] for t in targets if t in payloads} - - (run_dir / "manifest.json").write_text(json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8") - for target, payload in selected_payloads.items(): - d = packs_dir / target - d.mkdir(exist_ok=True) - (d / "payload.json").write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8") - body = payload.get("body") or payload.get("content") or "" - (d / "content.md").write_text(textwrap.dedent(f"""\ - --- - target: {target} - type: longform - mode: {payload.get('mode')} - title: {json.dumps(payload.get('title', ''), ensure_ascii=False)} - --- - - {body} - """), encoding="utf-8") - - preview = preview_markdown(data, payloads, targets, source_body, errors) - (run_dir / "preview.md").write_text(preview, encoding="utf-8") - (run_dir / "result.json").write_text(json.dumps({"status": "blocked" if errors else "prepared", "errors": errors, "targets": targets, "results": []}, ensure_ascii=False, indent=2), encoding="utf-8") - - print(json.dumps({"ok": not errors, "run_dir": str(run_dir), "preview": str(run_dir / "preview.md"), "targets": targets, "errors": errors}, ensure_ascii=False, indent=2)) - return 2 if errors else 0 + warnings.warn( + "prepare_longform.py is deprecated; use `python3 scripts/mmp.py publish `", + DeprecationWarning, + stacklevel=2, + ) + print( + "DEPRECATED. Run `python3 scripts/mmp.py publish ` instead.", + file=sys.stderr, + ) + return 1 if __name__ == "__main__": - raise SystemExit(main()) + sys.exit(main()) diff --git a/scripts/publish_manifest.py b/scripts/publish_manifest.py index 81e6a40..e7ea4bb 100755 --- a/scripts/publish_manifest.py +++ b/scripts/publish_manifest.py @@ -1,122 +1,27 @@ -#!/usr/bin/env python3 -"""Validate a multi-media-publisher manifest and create a run skeleton. +"""DEPRECATED in v0.2. Use `mmp validate `. -This intentionally does not publish. Dispatch should be handled by verified -platform skills with explicit user approval. +Logic moved to core/manifest.py + core/cli.py validate command. +This shim will be removed in v0.3. """ + from __future__ import annotations -import argparse -import datetime as dt -import json -import pathlib -import re import sys - -try: - import yaml # type: ignore -except Exception: - yaml = None - - -def simple_yaml_load(text: str) -> dict: - """Tiny fallback parser for the simple manifests in this skill.""" - data: dict = {} - stack: list[tuple[int, object]] = [(-1, data)] - last_key_at_indent: dict[int, str] = {} - for raw in text.splitlines(): - if not raw.strip() or raw.lstrip().startswith("#"): - continue - indent = len(raw) - len(raw.lstrip(" ")) - line = raw.strip() - while stack and indent <= stack[-1][0]: - stack.pop() - parent = stack[-1][1] - if line.startswith("- "): - item = line[2:].strip().strip('"\'') - if isinstance(parent, list): - parent.append(item) - continue - if ":" not in line: - continue - key, val = line.split(":", 1) - key = key.strip() - val = val.strip() - if val == "": - # Heuristic: common list containers vs mapping containers. - container = [] if key in {"targets", "images", "tags"} else {} - if isinstance(parent, dict): - parent[key] = container - stack.append((indent, container)) - last_key_at_indent[indent] = key - else: - val = val.split(" #", 1)[0].strip().strip('"\'') - if isinstance(parent, dict): - parent[key] = val - return data - - -def yaml_load(text: str) -> dict: - if yaml: - return yaml.safe_load(text) - return simple_yaml_load(text) - -REQUIRED = {"type", "title", "body", "targets", "mode"} -VALID_TYPES = {"image-post", "longform", "video-post"} -VALID_MODES = {"draft", "publish"} - - -def load_manifest(path: pathlib.Path) -> dict: - text = path.read_text(encoding="utf-8") - data = yaml_load(text) - if not isinstance(data, dict): - raise SystemExit("Manifest must be a YAML mapping/object") - return data - - -def validate(data: dict) -> list[str]: - errors: list[str] = [] - missing = sorted(REQUIRED - set(data)) - if missing: - errors.append(f"missing required fields: {', '.join(missing)}") - if data.get("type") not in VALID_TYPES: - errors.append(f"type must be one of {sorted(VALID_TYPES)}") - if data.get("mode") not in VALID_MODES: - errors.append(f"mode must be one of {sorted(VALID_MODES)}") - if not isinstance(data.get("targets"), list) or not data.get("targets"): - errors.append("targets must be a non-empty list") - if data.get("type") == "image-post": - images = ((data.get("assets") or {}).get("images") or []) if isinstance(data.get("assets"), dict) else [] - if not images: - errors.append("image-post requires assets.images") - return errors - - -def slugify(title: str) -> str: - s = re.sub(r"[^\w\u4e00-\u9fff-]+", "-", title.lower()).strip("-") - return s[:48] or "run" +import warnings def main() -> int: - ap = argparse.ArgumentParser() - ap.add_argument("manifest", type=pathlib.Path) - ap.add_argument("--runs-dir", type=pathlib.Path, default=pathlib.Path(__file__).resolve().parents[1] / "runs") - args = ap.parse_args() - - data = load_manifest(args.manifest) - errors = validate(data) - if errors: - print(json.dumps({"ok": False, "errors": errors}, ensure_ascii=False, indent=2)) - return 2 - - ts = dt.datetime.now().strftime("%Y%m%d-%H%M%S") - run_dir = args.runs_dir / f"{ts}-{slugify(str(data.get('title', 'run')))}" - run_dir.mkdir(parents=True, exist_ok=True) - (run_dir / "manifest.json").write_text(json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8") - (run_dir / "result.json").write_text(json.dumps({"status": "planned", "targets": data["targets"], "results": []}, ensure_ascii=False, indent=2), encoding="utf-8") - print(json.dumps({"ok": True, "run_dir": str(run_dir), "mode": data["mode"], "targets": data["targets"]}, ensure_ascii=False, indent=2)) - return 0 + warnings.warn( + "publish_manifest.py is deprecated; use `python3 scripts/mmp.py validate `", + DeprecationWarning, + stacklevel=2, + ) + print( + "DEPRECATED. Run `python3 scripts/mmp.py validate ` instead.", + file=sys.stderr, + ) + return 1 if __name__ == "__main__": - raise SystemExit(main()) + sys.exit(main()) diff --git a/scripts/test_local.py b/scripts/test_local.py index d8ef29b..0c1e4ae 100755 --- a/scripts/test_local.py +++ b/scripts/test_local.py @@ -1,180 +1,69 @@ #!/usr/bin/env python3 -"""Local smoke test for the multi-media-publisher MVP. +"""Local smoke test for multi-media-publisher v0.2. -Covers: -- py_compile for bundled scripts -- image-post prepare -- execute WeChat browser-flow guide -- Xiaohongshu local draft creation (no external publish) -- WeChat Official Account API dry-run from the generated bridge payload -- longform prepare for wechat-article/x-article/substack +Runs a few CLI flows in a tmp dir and asserts shape. Does NOT call any +external network. """ + from __future__ import annotations import json import os -import pathlib -import shutil -import struct import subprocess import sys import tempfile -import zlib - -ROOT = pathlib.Path(__file__).resolve().parents[1] -SCRIPTS = ROOT / "scripts" - - -def run(cmd: list[str], *, env: dict[str, str] | None = None, cwd: pathlib.Path | None = None, capture: bool = False) -> subprocess.CompletedProcess[str]: - print("+", " ".join(cmd)) - return subprocess.run(cmd, cwd=str(cwd or ROOT), env=env, text=True, capture_output=capture, check=True) - - -def write_png(path: pathlib.Path) -> None: - raw = b"\x00\x00\x00\x00\x00" +from pathlib import Path - def chunk(t: bytes, d: bytes) -> bytes: - return struct.pack(">I", len(d)) + t + d + struct.pack(">I", zlib.crc32(t + d) & 0xFFFFFFFF) +ROOT = Path(__file__).resolve().parent.parent - png = ( - b"\x89PNG\r\n\x1a\n" - + chunk(b"IHDR", struct.pack(">IIBBBBB", 1, 1, 8, 6, 0, 0, 0)) - + chunk(b"IDAT", zlib.compress(raw)) - + chunk(b"IEND", b"") - ) - path.write_bytes(png) - - - -def write_xhs_draft_stub(path: pathlib.Path) -> None: - path.parent.mkdir(parents=True, exist_ok=True) - path.write_text( - """#!/usr/bin/env bash -set -euo pipefail -mkdir -p "${XHS_DRAFT_DIR:?XHS_DRAFT_DIR required}" -out="$XHS_DRAFT_DIR/test-draft.json" -printf '%s' "$1" > "$out" -echo "✓ 已创建本地草稿: $out" -""", - encoding="utf-8", - ) - path.chmod(0o755) -def fixtures(tmp: pathlib.Path) -> pathlib.Path: - ex = tmp / "examples" - ex.mkdir(parents=True) - (ex / "caption.md").write_text("第一段正文。\n\n第二段正文。\n", encoding="utf-8") - (ex / "article.md").write_text("# 长文正文\n\n这是一篇用于本地测试的长文章。\n\n## 小节\n\n继续展开观点。\n", encoding="utf-8") - for name in ["01.png", "02.png", "cover.png"]: - write_png(ex / name) - (ex / "image-post.yaml").write_text( - """type: image-post -title: "AI 创业的三个误区" -body: ./caption.md -mode: draft -language: zh-CN -targets: - - xiaohongshu - - wechat-image -assets: - images: - - ./01.png - - ./02.png -tags: - - AI - - 创业 -cta: "欢迎留言聊聊你的看法。" -""", - encoding="utf-8", +def _run_mmp(*args, env_extra: dict | None = None) -> subprocess.CompletedProcess: + env = dict(os.environ) + if env_extra: + env.update(env_extra) + return subprocess.run( + [sys.executable, str(ROOT / "scripts" / "mmp.py"), *args], + capture_output=True, + text=True, + env=env, ) - (ex / "longform.yaml").write_text( - """type: longform -title: "AI Agent 不是工具,而是一种新的组织形态" -summary: "本地测试用摘要。" -body: ./article.md -mode: draft -language: zh-CN -targets: - - wechat-article - - x-article - - substack -assets: - cover: ./cover.png -tags: - - AI - - Agent - - 组织 -""", - encoding="utf-8", - ) - return ex def main() -> int: - tmp = pathlib.Path(os.environ.get("MMP_TEST_TMPDIR", tempfile.mkdtemp(prefix="mmp-local-test-"))).resolve() - if tmp.exists(): - shutil.rmtree(tmp) - tmp.mkdir(parents=True) - runs = tmp / "runs" - runs.mkdir() - ex = fixtures(tmp) - - scripts = [ - "adapt_content.py", - "publish_manifest.py", - "prepare_image_post.py", - "execute_image_post.py", - "wechat_api_draft.py", - "prepare_longform.py", - "test_local.py", + fixtures = [ + ROOT / "tests" / "fixtures" / "wechat-article-e2e.yaml", + ROOT / "tests" / "fixtures" / "image-post-multi.yaml", + ROOT / "tests" / "fixtures" / "longform-multi.yaml", ] - run([sys.executable, "-m", "py_compile", *[str(SCRIPTS / s) for s in scripts]]) - - image_prepare = run( - [sys.executable, str(SCRIPTS / "prepare_image_post.py"), str(ex / "image-post.yaml"), "--runs-dir", str(runs), "--copy-assets"], - capture=True, - ) - image_out = json.loads(image_prepare.stdout) - assert image_out["ok"], image_out - image_run = pathlib.Path(image_out["run_dir"]) - for rel in ["packs/xiaohongshu/payload.json", "packs/wechat-image/payload.json", "packs/wechat-article-api-bridge/payload.json", "preview.md"]: - assert (image_run / rel).exists(), rel - - run([sys.executable, str(SCRIPTS / "execute_image_post.py"), str(image_run), "--target", "wechat-image"]) - assert (image_run / "packs/wechat-image/browser-flow.md").exists() - - stub_xhs = tmp / "bin" / "draft.sh" - write_xhs_draft_stub(stub_xhs) - env = os.environ.copy() - env["XHS_DRAFT_DIR"] = str(tmp / "xhs-drafts") - env["MMP_XHS_DRAFT_SH"] = str(stub_xhs) - run([sys.executable, str(SCRIPTS / "execute_image_post.py"), str(image_run), "--target", "xiaohongshu", "--yes-draft"], env=env) - assert any((tmp / "xhs-drafts").glob("*.json")) - - api = run( - [sys.executable, str(SCRIPTS / "wechat_api_draft.py"), "draft-from-payload", str(image_run / "packs/wechat-article-api-bridge/payload.json"), "--dry-run"], - capture=True, - ) - api_out = json.loads(api.stdout) - assert api_out["ok"], api_out - assert api_out["draft"]["dry_run"] is True, api_out - assert api_out["upload"]["dry_run"] is True, api_out - - longform_prepare = run( - [sys.executable, str(SCRIPTS / "prepare_longform.py"), str(ex / "longform.yaml"), "--runs-dir", str(runs), "--copy-assets"], - capture=True, - ) - longform_out = json.loads(longform_prepare.stdout) - assert longform_out["ok"], longform_out - longform_run = pathlib.Path(longform_out["run_dir"]) - for rel in ["packs/wechat-article/payload.json", "packs/x-article/payload.json", "packs/substack/payload.json", "preview.md"]: - assert (longform_run / rel).exists(), rel - wechat = json.loads((longform_run / "packs/wechat-article/payload.json").read_text(encoding="utf-8")) - assert wechat["html"].startswith("

"), wechat - - print(json.dumps({"ok": True, "tmp": str(tmp), "image_run": str(image_run), "longform_run": str(longform_run)}, ensure_ascii=False, indent=2)) - return 0 + with tempfile.TemporaryDirectory(prefix="mmp-smoke-") as tmp: + tmp_path = Path(tmp) + runs_dir = tmp_path / "runs" + env_extra = { + "MMP_RUNS_DIR": str(runs_dir), + "XDG_CONFIG_HOME": str(tmp_path / "xdg"), + } + + for fix in fixtures: + p = _run_mmp("validate", str(fix), env_extra=env_extra) + assert p.returncode == 0, f"validate failed for {fix}:\n{p.stderr}" + + p = _run_mmp("publish", str(fix), env_extra=env_extra) + assert p.returncode == 0, f"publish dry-run failed for {fix}:\n{p.stderr}" + + # doctor + list + p = _run_mmp("doctor", env_extra=env_extra) + assert p.returncode == 0 + p = _run_mmp("list", "providers", env_extra=env_extra) + assert p.returncode == 0 + for prov in ("wechat-article", "xiaohongshu", "wechat-image", "x-article", "substack"): + assert prov in p.stdout, f"{prov} missing from list" + + runs = sorted((runs_dir).iterdir()) + print( + json.dumps({"ok": True, "tmp": str(tmp_path), "runs": [str(r) for r in runs]}, indent=2) + ) + return 0 if __name__ == "__main__": - raise SystemExit(main()) + sys.exit(main()) diff --git a/scripts/wechat_api_draft.py b/scripts/wechat_api_draft.py old mode 100755 new mode 100644 index d2b6a01..76fd73f --- a/scripts/wechat_api_draft.py +++ b/scripts/wechat_api_draft.py @@ -1,213 +1,28 @@ -#!/usr/bin/env python3 -"""WeChat Official Account draft API helper. +"""DEPRECATED: this CLI moved to `providers/wechat_article/internal/wechat_api.py`. -Draft-only by design. Public publishing/freepublish is intentionally not implemented. +It remains as a thin wrapper for backward compatibility with v0.1 scripts. +Will be removed in v0.3. """ + from __future__ import annotations -import argparse -import html -import json -import mimetypes -import os -import pathlib import sys -import urllib.parse -import urllib.request -from typing import Any - -API_BASE = "https://api.weixin.qq.com/cgi-bin" - - -def die(msg: str, code: int = 2) -> None: - print(json.dumps({"ok": False, "error": msg}, ensure_ascii=False, indent=2)) - raise SystemExit(code) - - -def post_json(url: str, payload: dict[str, Any]) -> dict[str, Any]: - req = urllib.request.Request(url, data=json.dumps(payload, ensure_ascii=False).encode("utf-8"), headers={"Content-Type": "application/json; charset=utf-8"}, method="POST") - with urllib.request.urlopen(req, timeout=30) as resp: - return json.loads(resp.read().decode("utf-8")) - - -def get_json(url: str) -> dict[str, Any]: - with urllib.request.urlopen(url, timeout=30) as resp: - return json.loads(resp.read().decode("utf-8")) - - -def multipart_upload(url: str, field_name: str, file_path: pathlib.Path) -> dict[str, Any]: - boundary = "----OpenClawMMPBoundary" - ctype = mimetypes.guess_type(str(file_path))[0] or "application/octet-stream" - body = [] - body.append(f"--{boundary}\r\n".encode()) - body.append(f'Content-Disposition: form-data; name="{field_name}"; filename="{file_path.name}"\r\n'.encode()) - body.append(f"Content-Type: {ctype}\r\n\r\n".encode()) - body.append(file_path.read_bytes()) - body.append(f"\r\n--{boundary}--\r\n".encode()) - data = b"".join(body) - req = urllib.request.Request(url, data=data, headers={"Content-Type": f"multipart/form-data; boundary={boundary}"}, method="POST") - with urllib.request.urlopen(req, timeout=60) as resp: - return json.loads(resp.read().decode("utf-8")) - - -def env_token() -> str | None: - return os.environ.get("WECHAT_ACCESS_TOKEN") - - -def get_token(app_id: str | None, app_secret: str | None, dry_run: bool = False) -> dict[str, Any]: - if env_token(): - return {"ok": True, "access_token": env_token(), "source": "WECHAT_ACCESS_TOKEN"} - if not app_id or not app_secret: - die("WECHAT_APP_ID and WECHAT_APP_SECRET are required unless WECHAT_ACCESS_TOKEN is set") - url = f"{API_BASE}/token?" + urllib.parse.urlencode({"grant_type": "client_credential", "appid": app_id, "secret": app_secret}) - if dry_run: - return {"ok": True, "dry_run": True, "method": "GET", "url": url.replace(app_secret, "***")} - data = get_json(url) - if "access_token" not in data: - die(f"failed to get access_token: {data}", 1) - return {"ok": True, "access_token": data["access_token"], "expires_in": data.get("expires_in"), "source": "api"} - - -def token_value(args: argparse.Namespace) -> str: - token = args.access_token or env_token() - if token: - return token - tok = get_token(os.environ.get("WECHAT_APP_ID"), os.environ.get("WECHAT_APP_SECRET")) - return str(tok["access_token"]) - - -def local_markdown_to_html(text: str) -> str: - # Minimal safe-ish conversion. Prefer a real Markdown renderer upstream. - parts = [] - for para in text.split("\n\n"): - para = para.strip() - if not para: - continue - if para.startswith("# "): - parts.append(f"

{html.escape(para[2:].strip())}

") - elif para.startswith("## "): - parts.append(f"

{html.escape(para[3:].strip())}

") - else: - parts.append("

" + html.escape(para).replace("\n", "
") + "

") - return "\n".join(parts) - - -def upload_thumb(token: str, file_path: pathlib.Path, dry_run: bool) -> dict[str, Any]: - if not file_path.exists(): - die(f"cover image not found: {file_path}") - url = f"{API_BASE}/material/add_material?" + urllib.parse.urlencode({"access_token": token, "type": "thumb"}) - if dry_run: - return {"ok": True, "dry_run": True, "method": "POST multipart", "url": url.replace(token, "***"), "file": str(file_path)} - data = multipart_upload(url, "media", file_path) - if "media_id" not in data: - die(f"failed to upload thumb: {data}", 1) - return {"ok": True, "media_id": data["media_id"], "url": data.get("url")} - - -def add_draft(token: str, articles: list[dict[str, Any]], dry_run: bool) -> dict[str, Any]: - payload = {"articles": articles} - url = f"{API_BASE}/draft/add?" + urllib.parse.urlencode({"access_token": token}) - if dry_run: - return {"ok": True, "dry_run": True, "method": "POST", "url": url.replace(token, "***"), "payload": payload} - data = post_json(url, payload) - if "media_id" not in data: - die(f"failed to add draft: {data}", 1) - return {"ok": True, "media_id": data["media_id"]} - - -def article_from_payload(payload: dict[str, Any], thumb_media_id: str | None) -> dict[str, Any]: - content = str(payload.get("html") or "") - if not content: - content = local_markdown_to_html(str(payload.get("content") or "")) - article = { - "title": str(payload.get("title") or "")[:64], - "author": str(payload.get("author") or ""), - "digest": str(payload.get("digest") or "")[:120], - "content": content, - "content_source_url": str(payload.get("content_source_url") or ""), - "thumb_media_id": thumb_media_id or str(payload.get("thumb_media_id") or ""), - "need_open_comment": int(payload.get("need_open_comment") or 0), - "only_fans_can_comment": int(payload.get("only_fans_can_comment") or 0), - } - if not article["title"]: - die("title is required") - if not article["content"]: - die("content/html is required") - if not article["thumb_media_id"]: - die("thumb_media_id is required; provide it or provide --cover to upload") - return article +import warnings def main() -> int: - ap = argparse.ArgumentParser() - sub = ap.add_subparsers(dest="cmd", required=True) - - sub.add_parser("check-env") - - p = sub.add_parser("get-token") - p.add_argument("--dry-run", action="store_true") - - p = sub.add_parser("upload-thumb") - p.add_argument("image", type=pathlib.Path) - p.add_argument("--access-token") - p.add_argument("--dry-run", action="store_true") - - p = sub.add_parser("add-draft") - p.add_argument("articles_json", type=pathlib.Path) - p.add_argument("--access-token") - p.add_argument("--dry-run", action="store_true") - - p = sub.add_parser("draft-from-payload") - p.add_argument("payload_json", type=pathlib.Path) - p.add_argument("--cover", type=pathlib.Path) - p.add_argument("--thumb-media-id") - p.add_argument("--access-token") - p.add_argument("--dry-run", action="store_true") - - args = ap.parse_args() - - if args.cmd == "check-env": - print(json.dumps({ - "ok": bool(env_token() or (os.environ.get("WECHAT_APP_ID") and os.environ.get("WECHAT_APP_SECRET"))), - "has_access_token": bool(env_token()), - "has_app_id": bool(os.environ.get("WECHAT_APP_ID")), - "has_app_secret": bool(os.environ.get("WECHAT_APP_SECRET")), - }, ensure_ascii=False, indent=2)) - return 0 - - if args.cmd == "get-token": - print(json.dumps(get_token(os.environ.get("WECHAT_APP_ID"), os.environ.get("WECHAT_APP_SECRET"), args.dry_run), ensure_ascii=False, indent=2)) - return 0 - - if args.cmd == "upload-thumb": - token = args.access_token or token_value(args) - print(json.dumps(upload_thumb(token, args.image, args.dry_run), ensure_ascii=False, indent=2)) - return 0 - - if args.cmd == "add-draft": - token = args.access_token or token_value(args) - articles = json.loads(args.articles_json.read_text(encoding="utf-8")) - if isinstance(articles, dict): - articles = articles.get("articles", []) - print(json.dumps(add_draft(token, articles, args.dry_run), ensure_ascii=False, indent=2)) - return 0 - - if args.cmd == "draft-from-payload": - token = args.access_token or ("DRY_RUN_TOKEN" if args.dry_run else token_value(args)) - payload = json.loads(args.payload_json.read_text(encoding="utf-8")) - thumb_media_id = args.thumb_media_id - upload_result = None - cover = args.cover or (pathlib.Path(str(payload.get("cover"))).expanduser() if payload.get("cover") else None) - if not thumb_media_id and cover: - upload_result = upload_thumb(token, cover, args.dry_run) - thumb_media_id = upload_result.get("media_id") or "DRY_RUN_THUMB_MEDIA_ID" - article = article_from_payload(payload, thumb_media_id) - result = add_draft(token, [article], args.dry_run) - print(json.dumps({"ok": True, "upload": upload_result, "draft": result}, ensure_ascii=False, indent=2)) - return 0 - + warnings.warn( + "scripts/wechat_api_draft.py is deprecated; use `mmp publish` or " + "`providers.wechat_article.internal.wechat_api`", + DeprecationWarning, + stacklevel=2, + ) + print( + "DEPRECATED. Use `python3 scripts/mmp.py publish ` instead.", + file=sys.stderr, + ) return 1 if __name__ == "__main__": - raise SystemExit(main()) + sys.exit(main()) diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/core/__init__.py b/tests/core/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/core/test_credentials.py b/tests/core/test_credentials.py new file mode 100644 index 0000000..6e753a1 --- /dev/null +++ b/tests/core/test_credentials.py @@ -0,0 +1,109 @@ +import pytest + +from core.credentials import CredentialStore, EnvBackend, FileBackend +from core.errors import MissingCredentialError + + +@pytest.fixture +def isolated_vault(monkeypatch, tmp_path): + monkeypatch.delenv("XDG_CONFIG_HOME", raising=False) + monkeypatch.setenv("HOME", str(tmp_path)) + monkeypatch.delenv("MMP_VAULT_KEY", raising=False) + return tmp_path + + +def test_env_vault_key_overrides_file(monkeypatch, tmp_path): + """MMP_VAULT_KEY ENV is the canonical source when set; file is fallback.""" + monkeypatch.delenv("XDG_CONFIG_HOME", raising=False) + monkeypatch.setenv("HOME", str(tmp_path)) + # generate a key out-of-band + import pyrage + + identity = pyrage.x25519.Identity.generate() + monkeypatch.setenv("MMP_VAULT_KEY", str(identity)) + + backend = FileBackend() + store = CredentialStore(backend=backend) + store.set("p", "default", {"K": "v"}) + + # Key file should NOT have been created when ENV is set + assert not (tmp_path / ".config" / "mmp" / "age-key.txt").exists() + + # And we can still read back + assert store.get("p", "default") == {"K": "v"} + + +def test_file_backend_roundtrip(isolated_vault): + backend = FileBackend() + store = CredentialStore(backend=backend) + store.set("wechat-article", "default", {"WECHAT_APP_ID": "wx", "WECHAT_APP_SECRET": "s"}) + + out = store.get("wechat-article", "default") + assert out == {"WECHAT_APP_ID": "wx", "WECHAT_APP_SECRET": "s"} + + +def test_file_backend_persists_encrypted(isolated_vault): + backend = FileBackend() + store = CredentialStore(backend=backend) + store.set("wechat-article", "default", {"WECHAT_APP_ID": "wx"}) + + vault_file = isolated_vault / ".config" / "mmp" / "credentials.json.age" + assert vault_file.exists() + raw = vault_file.read_bytes() + assert b"WECHAT_APP_ID" not in raw # encrypted, not visible + + +def test_get_missing_raises(isolated_vault): + store = CredentialStore(backend=FileBackend()) + with pytest.raises(MissingCredentialError): + store.get("wechat-article", "default") + + +def test_env_overrides_vault(isolated_vault, monkeypatch): + store = CredentialStore(backend=FileBackend()) + store.set("wechat-article", "default", {"WECHAT_APP_ID": "from_vault"}) + monkeypatch.setenv("WECHAT_APP_ID", "from_env") + + out = store.get("wechat-article", "default") + assert out["WECHAT_APP_ID"] == "from_env" + + +def test_list_accounts(isolated_vault): + store = CredentialStore(backend=FileBackend()) + store.set("wechat-article", "default", {"K": "v"}) + store.set("wechat-article", "lewis", {"K": "v2"}) + store.set("xiaohongshu", "default", {"K": "v3"}) + + assert sorted(store.list_accounts("wechat-article")) == ["default", "lewis"] + assert sorted(store.list_accounts(None)) == [ + "wechat-article:default", + "wechat-article:lewis", + "xiaohongshu:default", + ] + + +def test_delete(isolated_vault): + store = CredentialStore(backend=FileBackend()) + store.set("wechat-article", "default", {"K": "v"}) + store.delete("wechat-article", "default") + with pytest.raises(MissingCredentialError): + store.get("wechat-article", "default") + + +def test_env_only_backend(monkeypatch): + monkeypatch.setenv("WECHAT_APP_ID", "ww") + monkeypatch.setenv("WECHAT_APP_SECRET", "ss") + store = CredentialStore(backend=EnvBackend()) + out = store.get( + "wechat-article", + "default", + required_keys=["WECHAT_APP_ID", "WECHAT_APP_SECRET"], + ) + assert out == {"WECHAT_APP_ID": "ww", "WECHAT_APP_SECRET": "ss"} + + +def test_required_keys_filtering(isolated_vault): + store = CredentialStore(backend=FileBackend()) + store.set("wechat-article", "default", {"WECHAT_APP_ID": "wx", "EXTRA": "x"}) + out = store.get("wechat-article", "default", required_keys=["WECHAT_APP_ID"]) + assert out == {"WECHAT_APP_ID": "wx"} diff --git a/tests/core/test_errors.py b/tests/core/test_errors.py new file mode 100644 index 0000000..e0432bf --- /dev/null +++ b/tests/core/test_errors.py @@ -0,0 +1,36 @@ +from core.errors import ( + ManifestError, + MissingCredentialError, + MMPError, + PlatformRuleViolation, + ProviderExecutionError, + ProviderNotFoundError, +) + + +def test_hierarchy(): + assert issubclass(ManifestError, MMPError) + assert issubclass(ProviderNotFoundError, MMPError) + assert issubclass(MissingCredentialError, MMPError) + assert issubclass(PlatformRuleViolation, MMPError) + assert issubclass(ProviderExecutionError, MMPError) + + +def test_provider_execution_error_carries_metadata(): + upstream = ValueError("boom") + err = ProviderExecutionError( + target="wechat-article", + step="upload_thumb", + upstream=upstream, + retryable=True, + ) + assert err.target == "wechat-article" + assert err.step == "upload_thumb" + assert err.upstream is upstream + assert err.retryable is True + + +def test_missing_credential_error_carries_provider_and_keys(): + err = MissingCredentialError(provider="wechat-article", keys=["WECHAT_APP_ID"]) + assert err.provider == "wechat-article" + assert err.keys == ["WECHAT_APP_ID"] diff --git a/tests/core/test_host.py b/tests/core/test_host.py new file mode 100644 index 0000000..3583246 --- /dev/null +++ b/tests/core/test_host.py @@ -0,0 +1,43 @@ +from core import host + + +def test_user_data_dir_default(monkeypatch, tmp_path): + monkeypatch.delenv("XDG_CONFIG_HOME", raising=False) + monkeypatch.setenv("HOME", str(tmp_path)) + p = host.user_data_dir() + assert p == tmp_path / ".config" / "mmp" + + +def test_user_data_dir_xdg_override(monkeypatch, tmp_path): + monkeypatch.setenv("XDG_CONFIG_HOME", str(tmp_path / "xdg")) + p = host.user_data_dir() + assert p == tmp_path / "xdg" / "mmp" + + +def test_vault_paths(monkeypatch, tmp_path): + monkeypatch.delenv("XDG_CONFIG_HOME", raising=False) + monkeypatch.setenv("HOME", str(tmp_path)) + assert host.vault_path() == tmp_path / ".config" / "mmp" / "credentials.json.age" + assert host.vault_key_path() == tmp_path / ".config" / "mmp" / "age-key.txt" + + +def test_runs_dir_env_override(monkeypatch, tmp_path): + monkeypatch.setenv("MMP_RUNS_DIR", str(tmp_path / "runs")) + assert host.runs_dir() == tmp_path / "runs" + + +def test_detect_host_returns_string(): + h = host.detect_host() + assert h in {"claude-code", "openclaw", "unknown"} + + +def test_user_providers_dir(monkeypatch, tmp_path): + monkeypatch.delenv("XDG_CONFIG_HOME", raising=False) + monkeypatch.setenv("HOME", str(tmp_path)) + assert host.user_providers_dir() == tmp_path / ".config" / "mmp" / "providers" + + +def test_settings_path(monkeypatch, tmp_path): + monkeypatch.delenv("XDG_CONFIG_HOME", raising=False) + monkeypatch.setenv("HOME", str(tmp_path)) + assert host.settings_path() == tmp_path / ".config" / "mmp" / "settings.toml" diff --git a/tests/core/test_manifest.py b/tests/core/test_manifest.py new file mode 100644 index 0000000..82825b6 --- /dev/null +++ b/tests/core/test_manifest.py @@ -0,0 +1,122 @@ +from pathlib import Path + +import pytest + +from core.errors import ManifestError +from core.manifest import load_manifest + +FIXTURE = Path(__file__).parent.parent / "fixtures" / "longform-wechat.yaml" + + +def test_load_minimal(tmp_path): + p = tmp_path / "m.yaml" + p.write_text( + 'schema_version: "0.2"\n' + "type: image-post\n" + 'title: "Hi"\n' + 'body: "inline content"\n' + "mode: dry-run\n" + "targets:\n" + " - xiaohongshu\n" + ) + m = load_manifest(p) + assert m.schema_version == "0.2" + assert m.type == "image-post" + assert m.title == "Hi" + assert m.body == "inline content" + assert m.mode == "dry-run" + assert len(m.targets) == 1 + assert m.targets[0].name == "xiaohongshu" + assert m.targets[0].mode == "dry-run" # inherits top-level + assert m.targets[0].account == "default" + + +def test_load_fixture_full_form(): + m = load_manifest(FIXTURE) + assert m.type == "longform" + assert m.title == "Test Article" + # body got resolved from path + assert m.body.startswith("# Hello") + assert m.tags == ["test"] + assert len(m.targets) == 2 + + t1 = m.targets[0] + assert t1.name == "wechat-article" + assert t1.mode == "dry-run" # inherited + assert t1.account == "default" + + t2 = m.targets[1] + assert t2.name == "x-article" + assert t2.mode == "draft" # explicit override + assert t2.account == "lewis" + + +def test_missing_required_field_raises(tmp_path): + p = tmp_path / "m.yaml" + p.write_text("schema_version: '0.2'\ntype: longform\nmode: dry-run\ntargets: [foo]\n") + with pytest.raises(ManifestError, match="title"): + load_manifest(p) + + +def test_invalid_mode_raises(tmp_path): + p = tmp_path / "m.yaml" + p.write_text( + 'schema_version: "0.2"\ntype: longform\ntitle: x\nbody: x\n' + "mode: nuke\ntargets: [wechat-article]\n" + ) + with pytest.raises(ManifestError, match="mode"): + load_manifest(p) + + +def test_invalid_type_raises(tmp_path): + p = tmp_path / "m.yaml" + p.write_text( + 'schema_version: "0.2"\ntype: weird\ntitle: x\nbody: x\n' + "mode: dry-run\ntargets: [wechat-article]\n" + ) + with pytest.raises(ManifestError, match="type"): + load_manifest(p) + + +def test_to_lock_dict_normalized(): + m = load_manifest(FIXTURE) + lock = m.to_lock_dict() + assert lock["schema_version"] == "0.2" + assert lock["mode"] == "dry-run" + # targets always full-form in lock + assert all(isinstance(t, dict) for t in lock["targets"]) + assert lock["targets"][0]["name"] == "wechat-article" + assert lock["targets"][0]["mode"] == "dry-run" + assert lock["targets"][0]["account"] == "default" + + +def test_target_short_form_inherits_top_mode(tmp_path): + p = tmp_path / "m.yaml" + p.write_text( + 'schema_version: "0.2"\ntype: longform\ntitle: x\nbody: x\n' + "mode: draft\ntargets: [wechat-article, x-article]\n" + ) + m = load_manifest(p) + assert all(t.mode == "draft" for t in m.targets) + + +def test_defaults_block_applied(tmp_path): + p = tmp_path / "m.yaml" + p.write_text( + 'schema_version: "0.2"\ntype: longform\ntitle: x\nbody: x\n' + "mode: dry-run\ndefaults:\n account: lewis\n options:\n digest: hi\n" + "targets:\n - wechat-article\n" + ) + m = load_manifest(p) + assert m.targets[0].account == "lewis" + assert m.targets[0].options == {"digest": "hi"} + + +def test_body_path_explicit_missing_raises(tmp_path): + p = tmp_path / "m.yaml" + p.write_text( + 'schema_version: "0.2"\ntype: longform\ntitle: x\n' + "body: ./missing.md\nmode: dry-run\ntargets: [wechat-article]\n" + ) + with pytest.raises(ManifestError, match="body path not found"): + load_manifest(p) diff --git a/tests/core/test_provider.py b/tests/core/test_provider.py new file mode 100644 index 0000000..e390046 --- /dev/null +++ b/tests/core/test_provider.py @@ -0,0 +1,137 @@ +from pathlib import Path + +import pytest +import yaml + +from core.errors import ProviderNotFoundError +from core.provider import ( + ProviderRegistry, +) + + +def _write_provider_dir(root: Path, name: str, snake: str, body: str | None = None) -> Path: + """Write a fake provider package to disk and return its dir.""" + pdir = root / snake + pdir.mkdir(parents=True, exist_ok=True) + (pdir / "__init__.py").write_text("") + (pdir / "provider.yaml").write_text( + yaml.safe_dump( + { + "name": name, + "display_name": name, + "media_types": ["longform"], + "capabilities": {"draft": True, "publish": False, "schedule": False}, + "required_credentials": [{"key": "FAKE_KEY", "description": "x", "secret": True}], + "entry": "provider:FakeProvider", + "schema_version": 1, + } + ), + encoding="utf-8", + ) + pdir_body = ( + body + or f''' +from core.provider import Provider +from core.rules import PlatformRules + + +class FakeProvider(Provider): + name = "{name}" + display_name = "{name}" + media_types = ["longform"] + capabilities = {{"draft": True, "publish": False, "schedule": False}} + required_credentials = [] + platform_rules = PlatformRules() + + def validate(self, manifest, target): + return None + + def prepare(self, manifest, target, run_dir): + return None + + def execute(self, run_dir, target, mode, credentials): + return None +''' + ) + (pdir / "provider.py").write_text(pdir_body) + return pdir + + +def test_registry_discovers_bundled(tmp_path, monkeypatch): + bundled = tmp_path / "bundled" + bundled.mkdir() + _write_provider_dir(bundled, name="fake-one", snake="fake_one") + + reg = ProviderRegistry(bundled_dir=bundled, user_dir=tmp_path / "no_user") + reg.discover() + + info = reg.list() + assert any(i.name == "fake-one" for i in info) + + +def test_registry_resolve_by_kebab_name(tmp_path): + bundled = tmp_path / "bundled" + bundled.mkdir() + _write_provider_dir(bundled, name="fake-two", snake="fake_two") + + reg = ProviderRegistry(bundled_dir=bundled, user_dir=tmp_path / "no_user") + reg.discover() + + p = reg.resolve("fake-two") + assert p.name == "fake-two" + + +def test_resolve_missing_raises(tmp_path): + reg = ProviderRegistry(bundled_dir=tmp_path / "empty", user_dir=tmp_path / "empty2") + reg.discover() + with pytest.raises(ProviderNotFoundError): + reg.resolve("does-not-exist") + + +def test_user_provider_overrides_bundled(tmp_path): + bundled = tmp_path / "bundled" + user = tmp_path / "user" + bundled.mkdir() + user.mkdir() + + _write_provider_dir(bundled, name="dup", snake="dup_b") + _write_provider_dir( + user, + name="dup", + snake="dup_u", + body=( + "from core.provider import Provider\n" + "from core.rules import PlatformRules\n" + "class FakeProvider(Provider):\n" + " name = 'dup'\n" + " display_name = 'user-version'\n" + " media_types = ['longform']\n" + " capabilities = {'draft': True, 'publish': False, 'schedule': False}\n" + " required_credentials = []\n" + " platform_rules = PlatformRules()\n" + " def validate(self, m, t): return None\n" + " def prepare(self, m, t, r): return None\n" + " def execute(self, r, t, m, c): return None\n" + ), + ) + + reg = ProviderRegistry(bundled_dir=bundled, user_dir=user) + reg.discover(trust_user=True) + + p = reg.resolve("dup") + assert p.display_name == "user-version" + + +def test_filter_by_media_type(tmp_path): + bundled = tmp_path / "bundled" + bundled.mkdir() + _write_provider_dir(bundled, name="lf-only", snake="lf_only") + + reg = ProviderRegistry(bundled_dir=bundled, user_dir=tmp_path / "x") + reg.discover() + + longform = reg.list(media_type="longform") + assert any(i.name == "lf-only" for i in longform) + + images = reg.list(media_type="image-post") + assert all(i.name != "lf-only" for i in images) diff --git a/tests/core/test_rules.py b/tests/core/test_rules.py new file mode 100644 index 0000000..5dc3b5f --- /dev/null +++ b/tests/core/test_rules.py @@ -0,0 +1,51 @@ +from types import SimpleNamespace + +from core.rules import PlatformRules, Severity, Violation + + +def _stub_manifest(**overrides): + base = dict(title="hello", body="body", images=[], tags=[]) + base.update(overrides) + return SimpleNamespace(**base) + + +def test_violation_default_severity_error(): + v = Violation(code="X", message="x") + assert v.severity == Severity.error + + +def test_lint_title_max(): + rules = PlatformRules(title_max=10) + m = _stub_manifest(title="this is too long for the limit") + vs = rules.lint(m, target_name="xhs") + assert any(v.code == "TITLE_TOO_LONG" for v in vs) + + +def test_lint_image_count_min(): + rules = PlatformRules(image_count_min=3) + m = _stub_manifest(images=["a.png"]) + vs = rules.lint(m, target_name="xhs") + assert any(v.code == "IMAGE_COUNT_BELOW_MIN" for v in vs) + + +def test_lint_image_count_max(): + rules = PlatformRules(image_count_max=2) + m = _stub_manifest(images=["a.png", "b.png", "c.png"]) + vs = rules.lint(m, target_name="xhs") + assert any(v.code == "IMAGE_COUNT_ABOVE_MAX" for v in vs) + + +def test_lint_clean_passes(): + rules = PlatformRules(title_max=20, image_count_min=1, image_count_max=9) + m = _stub_manifest(title="short", images=["a.png"]) + assert rules.lint(m, target_name="xhs") == [] + + +def test_extra_lints_invoked(): + def custom(m, target_name): + return [Violation(code="CUSTOM", message="x", severity=Severity.warning)] + + rules = PlatformRules(extra_lints=[custom]) + m = _stub_manifest() + vs = rules.lint(m, target_name="any") + assert any(v.code == "CUSTOM" for v in vs) diff --git a/tests/core/test_run.py b/tests/core/test_run.py new file mode 100644 index 0000000..008da95 --- /dev/null +++ b/tests/core/test_run.py @@ -0,0 +1,94 @@ +import json + +from core.run import Run, slugify + + +def test_slugify_basic(): + assert slugify("Hello World") == "hello-world" + # Non-ASCII gets stripped; "AI 创业的三个误区" → "ai" only + assert slugify("AI 创业的三个误区") == "ai" + assert len(slugify("a" * 100)) <= 40 + assert slugify("") == "untitled" + assert slugify("!!!") == "untitled" + + +def test_run_create_dir(tmp_path, monkeypatch): + monkeypatch.setenv("MMP_RUNS_DIR", str(tmp_path / "runs")) + r = Run.create(title="Hello", mmp_version="0.2.0", host="claude-code", mode="draft") + assert r.dir.exists() + assert (r.dir / "packs").exists() + assert (r.dir / "checkpoints").exists() + assert (r.dir / "artifacts").exists() + assert "hello" in r.dir.name + + +def test_result_serialization(tmp_path, monkeypatch): + monkeypatch.setenv("MMP_RUNS_DIR", str(tmp_path / "runs")) + r = Run.create(title="X", mmp_version="0.2.0", host="cc", mode="draft") + r.add_target_result( + name="wechat-article", + account="default", + status="ok", + mode_actual="draft-platform", + external_id="m_123", + draft_url=None, + ) + r.finalize() + data = json.loads((r.dir / "result.json").read_text()) + assert data["mode"] == "draft" + assert data["targets"][0]["name"] == "wechat-article" + assert data["targets"][0]["status"] == "ok" + assert data["targets"][0]["external_id"] == "m_123" + + +def test_log_append(tmp_path, monkeypatch): + monkeypatch.setenv("MMP_RUNS_DIR", str(tmp_path / "runs")) + r = Run.create(title="X", mmp_version="0.2.0", host="cc", mode="draft") + r.log("RUN_START", run_id=r.run_id) + r.log("PREPARE_OK", target="wechat-article") + text = (r.dir / "publish-log.md").read_text() + assert "RUN_START" in text + assert "PREPARE_OK" in text + + +def test_checkpoint_write_read(tmp_path, monkeypatch): + monkeypatch.setenv("MMP_RUNS_DIR", str(tmp_path / "runs")) + r = Run.create(title="X", mmp_version="0.2.0", host="cc", mode="draft") + r.checkpoint("wechat-article", step="thumb_uploaded", external_ids={"thumb_id": "t1"}) + cp = r.read_checkpoint("wechat-article") + assert cp["step"] == "thumb_uploaded" + assert cp["external_ids"]["thumb_id"] == "t1" + + +def test_resume_loads_existing_dir(tmp_path, monkeypatch): + monkeypatch.setenv("MMP_RUNS_DIR", str(tmp_path / "runs")) + r = Run.create(title="Y", mmp_version="0.2.0", host="cc", mode="draft") + r.checkpoint("x-article", step="prepared") + run_dir = r.dir + + r2 = Run.from_dir(run_dir) + assert r2.run_id == r.run_id + assert r2.read_checkpoint("x-article")["step"] == "prepared" + + +def test_run_create_avoids_collision(tmp_path, monkeypatch): + monkeypatch.setenv("MMP_RUNS_DIR", str(tmp_path / "runs")) + r1 = Run.create(title="Same", mmp_version="0.2.0", host="cc", mode="draft") + r2 = Run.create(title="Same", mmp_version="0.2.0", host="cc", mode="draft") + assert r1.dir != r2.dir + assert r1.run_id != r2.run_id + # Second one should have a -2 suffix + assert r2.run_id.endswith("-2") + + +def test_finalize_with_no_targets(tmp_path, monkeypatch): + import json + + monkeypatch.setenv("MMP_RUNS_DIR", str(tmp_path / "runs")) + r = Run.create(title="Empty", mmp_version="0.2.0", host="cc", mode="dry-run") + r.finalize() + log = (r.dir / "publish-log.md").read_text() + assert "RUN_DONE" in log + assert "overall=empty" in log + data = json.loads((r.dir / "result.json").read_text()) + assert data["targets"] == [] diff --git a/tests/core/test_settings.py b/tests/core/test_settings.py new file mode 100644 index 0000000..238fea4 --- /dev/null +++ b/tests/core/test_settings.py @@ -0,0 +1,35 @@ +import pytest + +from core import settings + + +@pytest.fixture +def isolated(monkeypatch, tmp_path): + monkeypatch.delenv("XDG_CONFIG_HOME", raising=False) + monkeypatch.setenv("HOME", str(tmp_path)) + return tmp_path + + +def test_default_when_missing(isolated): + s = settings.load() + assert s.default_mode == "draft" + assert s.wizard_enabled is True + assert s.auto_save_manifest is True + assert s.trusted_user_providers == [] + + +def test_set_and_reload(isolated): + s = settings.load() + s.default_mode = "dry-run" + s.trusted_user_providers = ["my-thing"] + settings.save(s) + + s2 = settings.load() + assert s2.default_mode == "dry-run" + assert s2.trusted_user_providers == ["my-thing"] + + +def test_settings_file_path(isolated): + s = settings.load() + settings.save(s) + assert (isolated / ".config" / "mmp" / "settings.toml").exists() diff --git a/tests/core/test_wizard_commit.py b/tests/core/test_wizard_commit.py new file mode 100644 index 0000000..a94714c --- /dev/null +++ b/tests/core/test_wizard_commit.py @@ -0,0 +1,33 @@ +import pytest + +from core.errors import ManifestError +from core.wizard.commit import commit_manifest + +VALID_YAML = """\ +schema_version: "0.2" +type: longform +title: "Test" +body: "inline body" +mode: dry-run +targets: + - wechat-article +""" + + +def test_commit_valid(tmp_path, monkeypatch): + monkeypatch.setenv("MMP_RUNS_DIR", str(tmp_path / "runs")) + src = tmp_path / "src.yaml" + src.write_text(VALID_YAML, encoding="utf-8") + + run_dir = commit_manifest(src) + assert run_dir.exists() + assert (run_dir / "manifest.yaml").read_text() == VALID_YAML + assert (run_dir / "manifest.lock.json").exists() + + +def test_commit_invalid_raises(tmp_path, monkeypatch): + monkeypatch.setenv("MMP_RUNS_DIR", str(tmp_path / "runs")) + src = tmp_path / "src.yaml" + src.write_text("not valid yaml: ::", encoding="utf-8") + with pytest.raises(ManifestError): + commit_manifest(src) diff --git a/tests/core/test_wizard_context.py b/tests/core/test_wizard_context.py new file mode 100644 index 0000000..3080fce --- /dev/null +++ b/tests/core/test_wizard_context.py @@ -0,0 +1,189 @@ +from pathlib import Path + +import yaml + +from core.wizard.context import build_context + + +def _write_provider(root: Path, name: str, snake: str, media_types: list[str]) -> None: + pdir = root / snake + pdir.mkdir(parents=True, exist_ok=True) + (pdir / "__init__.py").write_text("") + (pdir / "provider.yaml").write_text( + yaml.safe_dump( + { + "name": name, + "display_name": name, + "media_types": media_types, + "capabilities": {"draft": True, "publish": False, "schedule": False}, + "required_credentials": [], + "entry": "provider:P", + "schema_version": 1, + } + ), + encoding="utf-8", + ) + (pdir / "provider.py").write_text( + "from core.provider import Provider\n" + "from core.rules import PlatformRules\n" + "class P(Provider):\n" + f" name = '{name}'\n" + f" display_name = '{name}'\n" + f" media_types = {media_types}\n" + " capabilities = {'draft': True, 'publish': False, 'schedule': False}\n" + " required_credentials = []\n" + " platform_rules = PlatformRules()\n" + " def validate(self, m, t): return None\n" + " def prepare(self, m, t, r): return None\n" + " def execute(self, r, t, m, c): return None\n" + ) + + +def test_context_lists_providers(tmp_path, monkeypatch): + bundled = tmp_path / "bundled" + bundled.mkdir() + _write_provider(bundled, name="lf-only", snake="lf_only", media_types=["longform"]) + _write_provider(bundled, name="img-only", snake="img_only", media_types=["image-post"]) + + monkeypatch.delenv("XDG_CONFIG_HOME", raising=False) + monkeypatch.setenv("HOME", str(tmp_path)) + + ctx = build_context(bundled_dir=bundled) + assert any(p["name"] == "lf-only" for p in ctx["providers"]) + assert any(p["name"] == "img-only" for p in ctx["providers"]) + + +def test_context_filters_by_type(tmp_path, monkeypatch): + bundled = tmp_path / "bundled" + bundled.mkdir() + _write_provider(bundled, name="lf-only", snake="lf_only", media_types=["longform"]) + _write_provider(bundled, name="img-only", snake="img_only", media_types=["image-post"]) + + monkeypatch.delenv("XDG_CONFIG_HOME", raising=False) + monkeypatch.setenv("HOME", str(tmp_path)) + + ctx = build_context(bundled_dir=bundled, media_type="longform") + names = [p["name"] for p in ctx["providers"]] + assert "lf-only" in names + assert "img-only" not in names + + +def test_context_includes_accounts_and_settings(tmp_path, monkeypatch): + monkeypatch.delenv("XDG_CONFIG_HOME", raising=False) + monkeypatch.setenv("HOME", str(tmp_path)) + + ctx = build_context(bundled_dir=tmp_path / "empty") + assert "accounts" in ctx + assert "settings" in ctx + assert ctx["settings"]["default_mode"] == "draft" + + +def test_context_marks_credential_status(tmp_path, monkeypatch): + bundled = tmp_path / "bundled" + bundled.mkdir() + pdir = bundled / "needy" + pdir.mkdir() + (pdir / "__init__.py").write_text("") + (pdir / "provider.yaml").write_text( + yaml.safe_dump( + { + "name": "needy", + "display_name": "needy", + "media_types": ["longform"], + "capabilities": {"draft": True, "publish": False, "schedule": False}, + "required_credentials": [{"key": "FOO", "description": "foo", "secret": True}], + "entry": "provider:P", + "schema_version": 1, + } + ), + encoding="utf-8", + ) + (pdir / "provider.py").write_text( + "from core.provider import Provider\n" + "from core.rules import PlatformRules\n" + "class P(Provider):\n" + " name='needy'\n display_name='needy'\n media_types=['longform']\n" + " capabilities={'draft': True, 'publish': False, 'schedule': False}\n" + " required_credentials=[]\n platform_rules=PlatformRules()\n" + " def validate(self,m,t): return None\n" + " def prepare(self,m,t,r): return None\n" + " def execute(self,r,t,m,c): return None\n" + ) + monkeypatch.delenv("XDG_CONFIG_HOME", raising=False) + monkeypatch.setenv("HOME", str(tmp_path)) + + ctx = build_context(bundled_dir=bundled) + p = next(p for p in ctx["providers"] if p["name"] == "needy") + assert p["credential_status"] == "missing" + + +def test_context_per_account_credential_status(tmp_path, monkeypatch): + """When the user has 'lewis' account configured but no 'default', + the provider's overall status should reflect lewis being ok.""" + bundled = tmp_path / "bundled" + bundled.mkdir() + pdir = bundled / "needy" + pdir.mkdir() + (pdir / "__init__.py").write_text("") + (pdir / "provider.yaml").write_text( + yaml.safe_dump( + { + "name": "needy", + "display_name": "needy", + "media_types": ["longform"], + "capabilities": {"draft": True, "publish": False, "schedule": False}, + "required_credentials": [{"key": "FOO", "description": "foo", "secret": True}], + "entry": "provider:P", + "schema_version": 1, + } + ), + encoding="utf-8", + ) + (pdir / "provider.py").write_text( + "from core.provider import Provider\n" + "from core.rules import PlatformRules\n" + "class P(Provider):\n" + " name='needy'\n display_name='needy'\n media_types=['longform']\n" + " capabilities={'draft': True, 'publish': False, 'schedule': False}\n" + " required_credentials=[]\n platform_rules=PlatformRules()\n" + " def validate(self,m,t): return None\n" + " def prepare(self,m,t,r): return None\n" + " def execute(self,r,t,m,c): return None\n" + ) + monkeypatch.delenv("XDG_CONFIG_HOME", raising=False) + monkeypatch.setenv("HOME", str(tmp_path)) + + # Configure 'lewis' account (not 'default') + from core.credentials import CredentialStore, FileBackend + + store = CredentialStore(backend=FileBackend()) + store.set("needy", "lewis", {"FOO": "bar"}) + + ctx = build_context(bundled_dir=bundled) + p = next(p for p in ctx["providers"] if p["name"] == "needy") + + # Provider-level: ok because at least one account has it + assert p["credential_status"] == "ok" + # Per-account list shows lewis specifically + assert {"name": "lewis", "status": "ok"} in p["accounts"] + # Top-level accounts dict has the per-provider grouping + assert ctx["accounts"]["needy"] == ["lewis"] + + +def test_context_accounts_grouped_by_provider(tmp_path, monkeypatch): + """Top-level accounts is a dict mapping provider name → list of account names.""" + monkeypatch.delenv("XDG_CONFIG_HOME", raising=False) + monkeypatch.setenv("HOME", str(tmp_path)) + + from core.credentials import CredentialStore, FileBackend + + store = CredentialStore(backend=FileBackend()) + store.set("p-one", "default", {"K": "v"}) + store.set("p-one", "alt", {"K": "v"}) + store.set("p-two", "default", {"K": "v"}) + + ctx = build_context(bundled_dir=tmp_path / "no_bundled") + accounts = ctx["accounts"] + assert isinstance(accounts, dict) + assert sorted(accounts["p-one"]) == ["alt", "default"] + assert accounts["p-two"] == ["default"] diff --git a/tests/core/test_wizard_loader.py b/tests/core/test_wizard_loader.py new file mode 100644 index 0000000..e7beba4 --- /dev/null +++ b/tests/core/test_wizard_loader.py @@ -0,0 +1,32 @@ +import pytest + +from core.wizard.loader import list_stages, render + + +def test_list_stages(): + stages = list_stages() + assert "source_extraction" in stages + assert "target_selection" in stages + assert "manifest_assembly" in stages + assert "credential_setup" in stages + + +def test_render_basic_substitution(tmp_path): + # write a tiny test prompt to a temp dir and render it + test_prompt = tmp_path / "demo.md" + test_prompt.write_text("Hello, {{name}}!\n", encoding="utf-8") + out = render(test_prompt, name="World") + assert out.strip() == "Hello, World!" + + +def test_render_missing_var_raises(tmp_path): + test_prompt = tmp_path / "x.md" + test_prompt.write_text("Hello {{missing}}", encoding="utf-8") + with pytest.raises(KeyError): + render(test_prompt) + + +def test_render_real_stage_returns_text(): + text = render("source_extraction") + assert len(text) > 0 + assert isinstance(text, str) diff --git a/tests/fixtures/image-post-multi.body.md b/tests/fixtures/image-post-multi.body.md new file mode 100644 index 0000000..b906960 --- /dev/null +++ b/tests/fixtures/image-post-multi.body.md @@ -0,0 +1,2 @@ +这是一组图文 caption。 +不超过 1000 字。 diff --git a/tests/fixtures/image-post-multi.yaml b/tests/fixtures/image-post-multi.yaml new file mode 100644 index 0000000..a4e50f0 --- /dev/null +++ b/tests/fixtures/image-post-multi.yaml @@ -0,0 +1,16 @@ +schema_version: "0.2" +type: image-post +title: "AI 创业的三个误区" +body: ./image-post-multi.body.md +mode: dry-run +language: zh-CN +targets: + - xiaohongshu + - wechat-image +assets: + images: + - ./img-01.png + - ./img-02.png +tags: + - AI + - 创业 diff --git a/tests/fixtures/img-01.png b/tests/fixtures/img-01.png new file mode 100644 index 0000000..bd5ede1 Binary files /dev/null and b/tests/fixtures/img-01.png differ diff --git a/tests/fixtures/img-02.png b/tests/fixtures/img-02.png new file mode 100644 index 0000000..bd5ede1 Binary files /dev/null and b/tests/fixtures/img-02.png differ diff --git a/tests/fixtures/longform-cover.png b/tests/fixtures/longform-cover.png new file mode 100644 index 0000000..bd5ede1 Binary files /dev/null and b/tests/fixtures/longform-cover.png differ diff --git a/tests/fixtures/longform-multi.body.md b/tests/fixtures/longform-multi.body.md new file mode 100644 index 0000000..cbb3664 --- /dev/null +++ b/tests/fixtures/longform-multi.body.md @@ -0,0 +1,3 @@ +# 一篇示例长文 + +正文段落。 diff --git a/tests/fixtures/longform-multi.yaml b/tests/fixtures/longform-multi.yaml new file mode 100644 index 0000000..d2ee828 --- /dev/null +++ b/tests/fixtures/longform-multi.yaml @@ -0,0 +1,18 @@ +schema_version: "0.2" +type: longform +title: "Example Longform" +body: ./longform-multi.body.md +mode: dry-run +language: zh-CN +targets: + - wechat-article + - x-article + - substack +assets: + cover: ./longform-cover.png +summary: "A quick summary." +metadata: + digest: "公众号摘要" + subtitle: "Substack subtitle" +tags: + - test diff --git a/tests/fixtures/longform-wechat.body.md b/tests/fixtures/longform-wechat.body.md new file mode 100644 index 0000000..fa093f1 --- /dev/null +++ b/tests/fixtures/longform-wechat.body.md @@ -0,0 +1,3 @@ +# Hello + +Some body text. diff --git a/tests/fixtures/longform-wechat.yaml b/tests/fixtures/longform-wechat.yaml new file mode 100644 index 0000000..b3e584f --- /dev/null +++ b/tests/fixtures/longform-wechat.yaml @@ -0,0 +1,15 @@ +schema_version: "0.2" +type: longform +title: "Test Article" +body: ./longform-wechat.body.md +mode: dry-run +language: zh-CN +targets: + - wechat-article + - target: x-article + mode: draft + account: lewis +assets: + cover: ./cover.png +tags: + - test diff --git a/tests/fixtures/wechat-article-cover.png b/tests/fixtures/wechat-article-cover.png new file mode 100644 index 0000000..bd5ede1 Binary files /dev/null and b/tests/fixtures/wechat-article-cover.png differ diff --git a/tests/fixtures/wechat-article-e2e.body.md b/tests/fixtures/wechat-article-e2e.body.md new file mode 100644 index 0000000..5e4b6e0 --- /dev/null +++ b/tests/fixtures/wechat-article-e2e.body.md @@ -0,0 +1,7 @@ +# AI Agent 不是工具 + +而是一种新的组织形态。 + +## 一 + +正文段落。 diff --git a/tests/fixtures/wechat-article-e2e.yaml b/tests/fixtures/wechat-article-e2e.yaml new file mode 100644 index 0000000..12da95a --- /dev/null +++ b/tests/fixtures/wechat-article-e2e.yaml @@ -0,0 +1,15 @@ +schema_version: "0.2" +type: longform +title: "AI Agent 不是工具" +body: ./wechat-article-e2e.body.md +mode: dry-run +language: zh-CN +targets: + - wechat-article +assets: + cover: ./wechat-article-cover.png +summary: "一句话摘要 测试" +tags: + - AI +metadata: + digest: "用作公众号文章摘要的字段" diff --git a/tests/integration/__init__.py b/tests/integration/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/integration/test_cli_skeleton.py b/tests/integration/test_cli_skeleton.py new file mode 100644 index 0000000..874c82c --- /dev/null +++ b/tests/integration/test_cli_skeleton.py @@ -0,0 +1,48 @@ +import subprocess +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parent.parent.parent + + +def _run(*args, env=None): + return subprocess.run( + [sys.executable, str(ROOT / "scripts" / "mmp.py"), *args], + capture_output=True, + text=True, + env=env, + ) + + +def test_no_args_prints_help(): + p = _run() + assert p.returncode != 0 + assert "usage" in (p.stdout + p.stderr).lower() + + +def test_help_lists_subcommands(): + p = _run("--help") + out = p.stdout + p.stderr + for cmd in ["validate", "publish", "setup", "list", "resume", "doctor"]: + assert cmd in out + + +def test_validate_rejects_unsupported_mode(tmp_path): + """mode=publish on a provider with capabilities.publish=false should fail validate.""" + src = tmp_path / "m.yaml" + src.write_text( + 'schema_version: "0.2"\n' + "type: longform\n" + 'title: "X"\n' + 'body: "hi"\n' + "mode: publish\n" + "targets: [wechat-article]\n", + encoding="utf-8", + ) + p = subprocess.run( + [sys.executable, str(ROOT / "scripts" / "mmp.py"), "validate", str(src)], + capture_output=True, + text=True, + ) + assert p.returncode == 2 + assert "MODE_NOT_SUPPORTED" in p.stderr diff --git a/tests/integration/test_image_post_e2e.py b/tests/integration/test_image_post_e2e.py new file mode 100644 index 0000000..5e4286b --- /dev/null +++ b/tests/integration/test_image_post_e2e.py @@ -0,0 +1,31 @@ +import json +import os +import subprocess +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parent.parent.parent +FIXTURE = ROOT / "tests" / "fixtures" / "image-post-multi.yaml" + + +def test_image_post_dry_run_both_targets(tmp_path): + p = subprocess.run( + [sys.executable, str(ROOT / "scripts" / "mmp.py"), "publish", str(FIXTURE)], + capture_output=True, + text=True, + env={**os.environ, "MMP_RUNS_DIR": str(tmp_path / "runs")}, + ) + assert p.returncode == 0, p.stderr + + runs = list((tmp_path / "runs").iterdir()) + rd = runs[0] + result = json.loads((rd / "result.json").read_text()) + names = [t["name"] for t in result["targets"]] + assert "xiaohongshu" in names + assert "wechat-image" in names + for t in result["targets"]: + assert t["status"] == "ok" + assert t["mode_actual"] == "dry-run" + + assert (rd / "packs" / "xiaohongshu" / "payload.json").exists() + assert (rd / "packs" / "wechat-image" / "payload.json").exists() diff --git a/tests/integration/test_longform_multi_e2e.py b/tests/integration/test_longform_multi_e2e.py new file mode 100644 index 0000000..f7a2c43 --- /dev/null +++ b/tests/integration/test_longform_multi_e2e.py @@ -0,0 +1,27 @@ +import json +import os +import subprocess +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parent.parent.parent +FIXTURE = ROOT / "tests" / "fixtures" / "longform-multi.yaml" + + +def test_longform_multi_dry_run(tmp_path): + p = subprocess.run( + [sys.executable, str(ROOT / "scripts" / "mmp.py"), "publish", str(FIXTURE)], + capture_output=True, + text=True, + env={**os.environ, "MMP_RUNS_DIR": str(tmp_path / "runs")}, + ) + assert p.returncode == 0, p.stderr + + rd = next((tmp_path / "runs").iterdir()) + result = json.loads((rd / "result.json").read_text()) + names = sorted(t["name"] for t in result["targets"]) + assert names == ["substack", "wechat-article", "x-article"] + for t in result["targets"]: + assert t["status"] == "ok" + for sub in ["wechat-article", "x-article", "substack"]: + assert (rd / "packs" / sub / "payload.json").exists() diff --git a/tests/integration/test_wechat_article_e2e.py b/tests/integration/test_wechat_article_e2e.py new file mode 100644 index 0000000..b7e76ce --- /dev/null +++ b/tests/integration/test_wechat_article_e2e.py @@ -0,0 +1,62 @@ +import json +import subprocess +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parent.parent.parent +FIXTURE = ROOT / "tests" / "fixtures" / "wechat-article-e2e.yaml" + + +def test_publish_dry_run_creates_run_dir(tmp_path, monkeypatch): + monkeypatch.setenv("MMP_RUNS_DIR", str(tmp_path / "runs")) + + p = subprocess.run( + [sys.executable, str(ROOT / "scripts" / "mmp.py"), "publish", str(FIXTURE)], + capture_output=True, + text=True, + env={**__import__("os").environ, "MMP_RUNS_DIR": str(tmp_path / "runs")}, + ) + assert p.returncode == 0, p.stderr + + runs = list((tmp_path / "runs").iterdir()) + assert len(runs) == 1 + rd = runs[0] + assert (rd / "manifest.lock.json").exists() + assert (rd / "result.json").exists() + assert (rd / "publish-log.md").exists() + assert (rd / "packs" / "wechat-article" / "payload.json").exists() + + result = json.loads((rd / "result.json").read_text()) + assert result["mode"] == "dry-run" + assert len(result["targets"]) == 1 + t = result["targets"][0] + assert t["name"] == "wechat-article" + assert t["status"] == "ok" + assert t["mode_actual"] == "dry-run" + + +def test_validate_subcommand(tmp_path): + p = subprocess.run( + [sys.executable, str(ROOT / "scripts" / "mmp.py"), "validate", str(FIXTURE)], + capture_output=True, + text=True, + ) + assert p.returncode == 0, p.stderr + assert "OK" in p.stdout + + +def test_run_dir_is_self_contained(tmp_path): + p = subprocess.run( + [sys.executable, str(ROOT / "scripts" / "mmp.py"), "publish", str(FIXTURE)], + capture_output=True, + text=True, + env={**__import__("os").environ, "MMP_RUNS_DIR": str(tmp_path / "runs")}, + ) + assert p.returncode == 0, p.stderr + + rd = next((tmp_path / "runs").iterdir()) + manifest_text = (rd / "manifest.yaml").read_text(encoding="utf-8") + # Body must be inlined, not a path reference + assert "./" not in manifest_text or "body:" not in manifest_text.split("./")[0].split("\n")[-1] + # Specifically, the AI Agent body content should be present + assert "AI Agent" in manifest_text diff --git a/tests/integration/test_wizard_cli.py b/tests/integration/test_wizard_cli.py new file mode 100644 index 0000000..94176be --- /dev/null +++ b/tests/integration/test_wizard_cli.py @@ -0,0 +1,80 @@ +import json +import os +import subprocess +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parent.parent.parent + + +def _run(*args, env_extra=None): + env = dict(os.environ) + if env_extra: + env.update(env_extra) + return subprocess.run( + [sys.executable, str(ROOT / "scripts" / "mmp.py"), *args], + capture_output=True, + text=True, + env=env, + ) + + +def test_dump_context_returns_json(tmp_path): + p = _run( + "wizard", + "--dump-context", + env_extra={ + "MMP_RUNS_DIR": str(tmp_path / "runs"), + "XDG_CONFIG_HOME": str(tmp_path / "xdg"), + }, + ) + assert p.returncode == 0 + data = json.loads(p.stdout) + assert "providers" in data + assert "accounts" in data + assert "settings" in data + + +def test_dump_context_filters_by_type(tmp_path): + p = _run( + "wizard", + "--dump-context", + "--type", + "longform", + env_extra={ + "MMP_RUNS_DIR": str(tmp_path / "runs"), + "XDG_CONFIG_HOME": str(tmp_path / "xdg"), + }, + ) + assert p.returncode == 0 + data = json.loads(p.stdout) + for prov in data["providers"]: + assert "longform" in prov["media_types"] + + +def test_commit_writes_run_dir(tmp_path): + src = tmp_path / "m.yaml" + src.write_text( + 'schema_version: "0.2"\n' + "type: longform\n" + 'title: "X"\n' + 'body: "hi"\n' + "mode: dry-run\n" + "targets: [wechat-article]\n", + encoding="utf-8", + ) + p = _run( + "wizard", + "--commit", + str(src), + env_extra={ + "MMP_RUNS_DIR": str(tmp_path / "runs"), + "XDG_CONFIG_HOME": str(tmp_path / "xdg"), + }, + ) + assert p.returncode == 0, p.stderr + assert "RUN_DIR" in p.stdout + runs = list((tmp_path / "runs").iterdir()) + assert len(runs) == 1 + assert (runs[0] / "manifest.yaml").exists() + assert (runs[0] / "manifest.lock.json").exists()