@zuroku/cli
Advanced tools
| # run-explainer-page examples | ||
| ## 参考の最終成果物 | ||
| `/Users/masao/playground/codex-image-editor/explainer/index.html` (482 行) | ||
| - hero: kicker `CODEX APP SERVER 解説` + h1 + lede + TL;DR 3 行 | ||
| - 7 sections (`01` 全体俯瞰 / `02` SDK / exec / app-server の使い分け / `03` 標準装備のツール / `04` 認証モデル / `05` image_gen の経済性 / `06` JSON-RPC の往復 / `07` BYO サブスク配布) | ||
| - 6 figures (section 03 と 07 を除き各 section に 1 枚) | ||
| - 結論 3 行 + footer | ||
| - accent: `#10a37f` (ChatGPT green) | ||
| ## 参考の中間 context | ||
| `/Users/masao/playground/codex-image-editor/explainer/context.md` (211 行) | ||
| - 過去会話のユーザー発言抜粋 (主要 5 問の出典) | ||
| - 主要 5 問への回答素材 | ||
| - HTML 構成案 (1 カラム縦長) | ||
| - detail-illustration プロンプト草案 | ||
| ## 入力例 → slug の付け方 | ||
| | 入力 (`$ARGUMENTS`) | slug | 想定 subagent | | ||
| |---|---|---| | ||
| | `Codex App Server とは` | `codex-app-server` | web research | | ||
| | `/Users/masao/playground/codex-image-editor` | `codex-image-editor` | delegate-explorer | | ||
| | `MCP vs Skill の違い` | `mcp-vs-skill` | web research | | ||
| | `Anthropic の computer use` | `anthropic-computer-use` | web research | | ||
| | `自分の repo の {/path/to/repo} を解説` | `<repo-name>` | delegate-explorer | | ||
| ## アクセント色の選び方 | ||
| トピックの主役プロダクトのブランドカラーを 1 色だけ拾う。複数ブランドが並ぶ比較記事 (`MCP vs Skill` / `OpenAI vs Anthropic`) の場合は中立色 (default の `#10a37f` か `#cc785c`) を選ぶ。 | ||
| ## figure の枚数判断ガイド | ||
| | section の中身 | figure を置く? | | ||
| |---|---| | ||
| | 概念図で構造を一発理解させたい (システム俯瞰 / シーケンス / 関係図) | はい | | ||
| | 比較表だけで自明 (3 列 × 5 行など) | いいえ (table のみ) | | ||
| | 経済性 / 数字が主役 | はい (天秤 / 折れ線のメタファー画像) | | ||
| | コード抜粋がメイン | いいえ (pre のみ) | | ||
| | リスク / 注意点の列挙 | いいえ (callout / risk table のみ) | |
| --- | ||
| name: run-explainer-page | ||
| template: domain | ||
| description: 入力 context から図解付きの 1 枚 HTML 解説ページを作りたい場面で発動。「解説ページ作って」「explainer page」で発動。 | ||
| user-invocable: true | ||
| argument-hint: "[topic | URL | file path]" | ||
| --- | ||
| # run-explainer-page | ||
| ユーザー入力 (topic 文字列 / URL / ファイルパス / 直前会話) を起点に、 | ||
| **1 枚 HTML の図解付き解説ページ** を生成する orchestrator。 | ||
| ## Input / Output | ||
| - **Input**: `$ARGUMENTS` = topic 文字列 / URL / ローカルファイルパス のいずれか。**空なら直前会話を context として拾う** | ||
| - **Output**: `output/explainer-{slug}/index.html` (CSS インライン 1 ファイル + `images/concept-NN-01.png` 群) | ||
| - **副産物**: `output/explainer-{slug}/{context.md, outline.json}` (再生成用) | ||
| ## 最重要 (compaction 切り捨て対策) | ||
| - **1 ターンで Phase 1-5 全部走る** (フェーズ間で応答終了しない、SubagentStop hook が二重発火する) | ||
| - **画像は engine 直叩き + STYLE 行固定** (`wrap-ai-image-detail-illustration` を Skill 経由で呼ぶと 4 並列メタファー振りで意図とずれる。`run-ai-images/scripts/generate.sh -n 1` を直接呼び、再走時も S1 STYLE 行は変えない) | ||
| - **1 枚 HTML 縛り** (CDN を引かない、`<style>` インライン + system font stack で完結) | ||
| - **fork 配下なら Agent ツール不可** (`run-pipe-line` / `run-eval-loop-fork` 等から呼ばれた場合は Phase 1 を `delegate-explorer` 1 本に縮退、§Phase 1 末尾参照) | ||
| 詳細・他の落とし穴は **Gotchas** に集約。 | ||
| ## いつ使う / 使わない | ||
| | 用途 | 使うスキル | | ||
| |---|---| | ||
| | **figure 付き 1 枚 HTML 解説ページ** | **`run-explainer-page`** ← この skill | | ||
| | SEO ブログ記事 (Markdown) | `run-seo-blog` | | ||
| | スライド (PDF) | `run-slide` | | ||
| | 説明画像 4 並列 (HTML 化なし) | `wrap-ai-image-detail-illustration` | | ||
| | 任意プロンプト画像 1 枚 | `run-ai-images` | | ||
| ## Phase 1: Research (subagent fan-out) | ||
| 入力をパースして以下のいずれか or 両方の subagent を起動する。**親で要約だけ受け取る** (探索結果を主コンテキストに吸わせない)。 | ||
| ### 判定ルール | ||
| - 入力に **ローカルパス / リポジトリ名 / コードベース題材** を含む → `delegate-explorer` | ||
| - 入力が **抽象トピック / 一般技術ネタ** → 内蔵 web research subagent (Agent ツール `subagent_type: general-purpose`) | ||
| - 両方ありうるなら **並列 fire** (1 メッセージで Agent + Skill を同時に呼ぶ) | ||
| ### fork 縮退モード (最重要 §4 の詳細) | ||
| parent が fork context (`run-pipe-line` / `run-eval-loop-fork` 等の配下) で呼ばれると Agent ツールが使えない。その場合は **`delegate-explorer` 1 本に縮退** する: | ||
| - ローカル context あり → `delegate-explorer` で従来どおり読み込み | ||
| - 抽象トピックのみ → `delegate-explorer` を **WebSearch / WebFetch hint 付き** で起動 (delegate-explorer は両ツールを持つ)。query に「公式ドキュメントを WebFetch で読んで〜」と明示する | ||
| ### delegate-explorer の呼び方 | ||
| ``` | ||
| Skill({ | ||
| skill: "delegate-explorer", | ||
| args: "<検索クエリ。題材コードベースで何を読んで context.md にするか具体的に。\ | ||
| 例: 'codex-image-editor の Tauri spawn 部分 (src-tauri/src/codex/process.rs) と \ | ||
| image watcher (src-tauri/src/images/watcher.rs) を読んで、JSON-RPC 通信と \ | ||
| image_gen の経済性に関わる実装抜粋を 300 語以内で'>" | ||
| }) | ||
| ``` | ||
| ### Web research subagent prompt (Agent ツール) | ||
| ``` | ||
| Agent({ | ||
| description: "explainer-page Phase 1 web research", | ||
| subagent_type: "general-purpose", | ||
| prompt: "<下記の指示文をそのまま渡す>" | ||
| }) | ||
| ``` | ||
| 指示文テンプレ: | ||
| ``` | ||
| あなたは 1 枚 HTML 解説ページの context 作成役。トピック: 「<TOPIC>」 | ||
| 以下を 500 語以内のサマリで返す: | ||
| 1. トピックの 1 行定義 (権威ある定義に揃える、必要なら WebSearch / WebFetch で公式ドキュメント取得) | ||
| 2. TL;DR 3 行 (各 1 文) | ||
| 3. 主要 5-7 問 (= 解説で答えるべき問い)。良い問いの例: 「なぜ X ではなく Y なのか」「X の正体は何か」「X を使う条件は何か」 | ||
| 4. 各問いへの簡潔な回答素材 (確証ある事実のみ。仮説には [仮説] と明記) | ||
| 5. 結論 3 行 | ||
| 6. 図解候補 3-8 個 (各 figure ごとにメタファー / レイアウト案 1 行) | ||
| 7. 数字 (価格 / 性能 / リリース日 等) は 必ず出典 URL と取得日を併記する | ||
| 主要 5-7 問は必ず捻り出す。トピックが抽象的でも仮説で踏み込んで構わない。 | ||
| 出力は親が context.md に流し込むのでマークダウン書式で返す。 | ||
| ``` | ||
| ### context.md 構成 (親が subagent サマリを統合して Write) | ||
| `output/explainer-{slug}/context.md` に以下を書く。slug はトピックから kebab-case (例: `codex-app-server` / `mcp-vs-skill` / `byo-llm-distribution`): | ||
| ```markdown | ||
| # {topic} — context | ||
| ## 1 行定義 | ||
| ... | ||
| ## TL;DR | ||
| 1. ... | ||
| 2. ... | ||
| 3. ... | ||
| ## 主要 5-7 問 | ||
| 1. **<問い>** | ||
| - <回答素材> | ||
| 2. ... | ||
| ## 結論 | ||
| 1. ... | ||
| 2. ... | ||
| 3. ... | ||
| ## 図解候補 | ||
| - figure-01: <概念> / メタファー: <...> / レイアウト: <...> | ||
| - figure-02: ... | ||
| ``` | ||
| ## Phase 2: Outline 設計 (orchestrator が直接) | ||
| context.md を読んで HTML 構成を JSON で確定する。**figure を置くか否かは Claude が判断** (情報量があり概念図にして伝わるなら置く / 表だけで自明なら省略)。最終 figure 数は 3-8 のレンジ。 | ||
| `output/explainer-{slug}/outline.json` に Write: | ||
| ```json | ||
| { | ||
| "slug": "codex-app-server", | ||
| "accent": "#10a37f", | ||
| "hero": { | ||
| "kicker": "CODEX APP SERVER 解説", | ||
| "h1": "Codex App Server とは — 自分の ChatGPT サブスクで動く AI アプリの裏側", | ||
| "lede": "<2-3 文の lede>", | ||
| "tldr": ["...", "...", "..."] | ||
| }, | ||
| "sections": [ | ||
| { | ||
| "num": "01", | ||
| "title": "全体俯瞰 — 何が「裏で」起きているか", | ||
| "sub": "<sub 1 行>", | ||
| "figure": { | ||
| "concept": "<図で何を伝えるか 1 行>", | ||
| "metaphor": "<具体的なメタファー (例: ハブ駅 / 執事 / ハイテクキッチン)>", | ||
| "layout": "<配置の指示 (例: 中央ブロック + 左右に矢印)>", | ||
| "alt": "<img alt>", | ||
| "caption": "<figcaption>" | ||
| }, | ||
| "blocks": [ | ||
| { "type": "p", "text": "..." }, | ||
| { "type": "callout", "text": "<本文>" }, | ||
| { "type": "table", "headers": ["...","..."], "rows": [["...","..."]], "pick_row": 0 }, | ||
| { "type": "pre", "lang": "rust", "code": "..." }, | ||
| { "type": "callout-warn", "text": "..." }, | ||
| { "type": "list", "items": ["...", "..."] }, | ||
| { "type": "quote", "text": "..." } | ||
| ] | ||
| } | ||
| ], | ||
| "conclusion": ["...", "...", "..."], | ||
| "footer": "題材: <path or URL>" | ||
| } | ||
| ``` | ||
| `figure: null` で figure なし section も許容するが、その場合は table / callout / pre のいずれかで視覚的メリハリを必ず出す (ベタテキスト section は退屈)。 | ||
| ## Phase 3: 画像 fan-out | ||
| outline の **figure を持つ section** に対して、`run-ai-images/scripts/generate.sh` を `-n 1` で並列 fire。 | ||
| ### プロンプト本文 (`wrap-ai-image-detail-illustration` SKILL.md の組み立て本文を流用) | ||
| S1 (グラレコ手描きマーカー) を STYLE 固定。aspect は 16:9。 | ||
| ``` | ||
| 説明画像、16:9、グラレコ風手描きマーカータッチ、温かみのある配色、和紙のような背景、手書き線画。 | ||
| 【テーマ】<section.title から派生> | ||
| 【構成/レイアウト】<section.figure.layout> | ||
| 【メタファー】<section.figure.metaphor> | ||
| 【必須要素】 | ||
| - タイトル(日本語、大きめ) | ||
| - サブタイトル or キャッチコピー(日本語) | ||
| - 各要素にアイコン・絵文字を添える(👍 👎 ✅ ⚠️ 💡 ⚙️ 🚀 📚 等) | ||
| - メリット/デメリット/ポイントは箇条書きで 3 点ずつ | ||
| 【スタイル】 | ||
| - 手描き風、温かみ、情報量多め | ||
| - 日本語ラベル・テキストを読みやすいサイズで | ||
| - 視線誘導(矢印・天秤・番号など) | ||
| 【禁止】抽象的な幾何模様だけで終わらせない、英語ラベルにしない、情報スカスカにしない | ||
| ``` | ||
| ### 並列 fire パターン (Bash バックグラウンド) | ||
| prefix は `concept-{NN}` (NN = section.num)。1 メッセージで全 figure を background 起動 → `wait` で揃え: | ||
| ```bash | ||
| mkdir -p output/explainer-{slug}/images | ||
| ( bash .claude/skills/run-ai-images/scripts/generate.sh \ | ||
| -o output/explainer-{slug}/images/concept-01 --aspect 16:9 -n 1 \ | ||
| -p "<section 01 のプロンプト本文>" > /tmp/explainer-01.log 2>&1 ) & | ||
| ( bash .claude/skills/run-ai-images/scripts/generate.sh \ | ||
| -o output/explainer-{slug}/images/concept-02 --aspect 16:9 -n 1 \ | ||
| -p "<section 02 のプロンプト本文>" > /tmp/explainer-02.log 2>&1 ) & | ||
| # ... 必要数まで | ||
| wait | ||
| ``` | ||
| 各 worker の出力は `concept-NN-01.png` (engine の suffix 付与は -n 1 でも変わらない)。HTML から `images/concept-NN-01.png` で参照する。 | ||
| ### 部分リトライ | ||
| 不合格があれば、その NN だけ同じ prefix で再 fire (上書きされる)。プロンプト本文の S1 STYLE 行は **再走時も変えない** (スタイル一貫性)。 | ||
| ## Phase 4: HTML 組み立て | ||
| `templates/skeleton.html` を Read してスタイル・基本構造を把握し、outline.json に従って `output/explainer-{slug}/index.html` を Write する。 | ||
| ### ブロック → HTML 対応 | ||
| | outline block.type | HTML | | ||
| |---|---| | ||
| | `p` | `<p>{text}</p>` | | ||
| | `list` | `<ul><li>...</li></ul>` | | ||
| | `table` | `<table class="table"><thead><tr><th>...</th></tr></thead><tbody><tr class="pick">...</tr></tbody></table>` (`pick_row` で推し行) | | ||
| | `pre` | `<pre>` + 簡易ハイライト span (`c`=コメント / `k`=キーワード / `s`=文字列 / `n`=識別子)。lang は読み手向けの参考扱い | | ||
| | `callout` | `<div class="callout"><p>{text}</p></div>` | | ||
| | `callout-warn` | `<div class="callout warn"><p>{text}</p></div>` | | ||
| | `quote` | `<div class="quote">{text}</div>` | | ||
| ### figure ブロック | ||
| ```html | ||
| <figure> | ||
| <img src="images/concept-NN-01.png" alt="{alt}"> | ||
| <figcaption>{caption}</figcaption> | ||
| </figure> | ||
| ``` | ||
| `NN` は `section.num` と完全一致させる。 | ||
| ### アクセント色の決め方 | ||
| `outline.accent` で決め打ち。トピックに応じて Claude が選んでよい: | ||
| | トピック傾向 | accent 候補 | | ||
| |---|---| | ||
| | ChatGPT / OpenAI 系 | `#10a37f` (ChatGPT green) | | ||
| | Anthropic 系 | `#cc785c` (Anthropic 橙) | | ||
| | AWS 系 | `#ff9900` | | ||
| | 汎用 / 迷ったら | `#10a37f` (参考 explainer の default) | | ||
| **置換手順**: Read で `templates/skeleton.html` を読み込み、`:root { --accent: #10a37f;` の `#10a37f` を `outline.accent` の値に文字列置換 (`--accent-soft` も同系の薄色に揃える、例: `#10a37f` → `#d6f0e6` / `#cc785c` → `#f5e0d6`)。置換後の本文に各 section / hero / conclusion を流し込んで `output/explainer-{slug}/index.html` に Write。`templates/skeleton.html` 自体は **書き換えない** (テンプレ汚染防止)。 | ||
| ## Phase 5: open | ||
| ```bash | ||
| open output/explainer-{slug}/index.html | ||
| ``` | ||
| ブラウザで目視確認。日本語ラベルが化けていない / figure が情報スカスカではない / アクセント色が崩れていない を見る。 | ||
| ## Gotchas | ||
| - **入力が薄い (1 行 topic) と context.md がスカスカ** → Phase 1 subagent prompt に「主要 5-7 問は必ず捻り出す / 結論まで仮説で踏み込む」を埋め込んでいるのは、これを外すと HTML が文字数だけ稼いで中身ゼロになるため。指示文から外さない | ||
| - **section.num と画像 prefix のずれ** → outline.json `"num": "01"` ↔ 画像 `concept-01-01.png` を 1:1 で紐付ける。ゼロパディング (01 / 02 / ... / 09 / 10) を統一すると img src 生成バグが減る (HTML 側 `images/concept-NN-01.png` の NN を文字列連結で組むため) | ||
| - **アクセント色は 1 色** → CSS 変数 `--accent` を 1 色だけ振る。複数アクセント (緑 + 橙を併用等) は情報設計の階層を壊す | ||
| - **figure なし section をベタ `<p>` だけにしない** → table / callout / pre のいずれかを必ず置く (退屈さは離脱を生む) | ||
| - **再生成時に context.md / outline.json を勝手に消さない** → 同じ slug で再走したら既存ファイルの diff を提示。ユーザーが「再生成して」と言わない限り上書きしない | ||
| - **数字には出典** → Phase 1 で取得した価格 / 性能数字は出典 URL を outline に入れて `<p style="font-size: 13px; color: var(--ink-2);">※ 出典: ...</p>` で section 末尾に置く。出典なしの数字を断定形で書かない | ||
| ## Additional resources | ||
| - `templates/skeleton.html` — CSS トークン (`--ink` / `--bg` / `--paper` / `--line` / `--accent` / `--accent-soft` / `--warn` / `--warn-soft` / `--yellow` / `--shadow` / `--mono` / `--sans`) と body 骨格 (`.wrap` > `header.hero` > `section[]` > `.conclusion` > `footer`) を持つ最小スケルトン | ||
| - `examples.md` — 参考の最終成果物 (`/Users/masao/playground/codex-image-editor/explainer/index.html`) と context.md の引用 | ||
| - 画像 engine: `.claude/skills/run-ai-images/scripts/generate.sh` (引数仕様はそちらの先頭コメント参照) | ||
| - メタプロンプト本文の本家: `.claude/skills/wrap-ai-image-detail-illustration/SKILL.md` の「組み立て本文」節 |
| <!doctype html> | ||
| <html lang="ja"> | ||
| <head> | ||
| <meta charset="utf-8"> | ||
| <meta name="viewport" content="width=device-width,initial-scale=1"> | ||
| <title>{{TITLE}}</title> | ||
| <style> | ||
| :root { | ||
| --ink: #1a1a1a; | ||
| --ink-2: #4a4a4a; | ||
| --bg: #f7f5ee; | ||
| --paper: #fbf9f1; | ||
| --line: #d8d2bf; | ||
| --accent: #10a37f; /* Phase 4 で outline.accent に置換 */ | ||
| --accent-soft: #d8efe5; | ||
| --warn: #c83a2c; | ||
| --warn-soft: #f6dcd6; | ||
| --yellow: #f4d35e; | ||
| --shadow: 0 1px 0 rgba(0,0,0,.04), 0 8px 24px rgba(0,0,0,.06); | ||
| --mono: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace; | ||
| --sans: -apple-system, BlinkMacSystemFont, "Hiragino Kaku Gothic ProN", | ||
| "Hiragino Sans", "Noto Sans JP", "Yu Gothic", "Meiryo", sans-serif; | ||
| } | ||
| * { box-sizing: border-box; } | ||
| html, body { margin: 0; padding: 0; } | ||
| body { | ||
| font-family: var(--sans); | ||
| background: var(--bg); | ||
| color: var(--ink); | ||
| line-height: 1.7; | ||
| -webkit-font-smoothing: antialiased; | ||
| } | ||
| .wrap { max-width: 1080px; margin: 0 auto; padding: 56px 28px 96px; } | ||
| /* hero */ | ||
| .hero { margin-bottom: 56px; } | ||
| .hero .kicker { | ||
| display: inline-block; | ||
| background: var(--accent); color: #fff; | ||
| font-size: 12px; font-weight: 700; letter-spacing: .12em; | ||
| padding: 6px 12px; border-radius: 999px; | ||
| margin-bottom: 18px; | ||
| } | ||
| .hero h1 { | ||
| font-size: 40px; line-height: 1.3; margin: 0 0 14px; | ||
| letter-spacing: -.01em; | ||
| } | ||
| .hero h1 b { color: var(--accent); } | ||
| .hero p.lede { | ||
| font-size: 18px; color: var(--ink-2); margin: 0; max-width: 780px; | ||
| } | ||
| .tldr { | ||
| margin-top: 28px; padding: 22px 24px; | ||
| background: var(--paper); border: 1px solid var(--line); border-radius: 12px; | ||
| box-shadow: var(--shadow); | ||
| } | ||
| .tldr h2 { font-size: 13px; letter-spacing: .15em; color: var(--accent); margin: 0 0 10px; } | ||
| .tldr ol { margin: 0; padding-left: 20px; } | ||
| .tldr li { margin: 4px 0; } | ||
| /* sections */ | ||
| section { margin: 64px 0 0; } | ||
| section > h2 { | ||
| font-size: 28px; line-height: 1.4; margin: 0 0 6px; | ||
| border-left: 6px solid var(--accent); padding-left: 12px; | ||
| } | ||
| section > h2 .num { | ||
| color: var(--accent); font-variant-numeric: tabular-nums; margin-right: 10px; | ||
| } | ||
| section > p.sub { color: var(--ink-2); margin: 0 0 22px; } | ||
| figure { | ||
| margin: 0 0 24px; padding: 12px; | ||
| background: var(--paper); border: 1px solid var(--line); border-radius: 14px; | ||
| box-shadow: var(--shadow); | ||
| } | ||
| figure img { display: block; width: 100%; height: auto; border-radius: 8px; } | ||
| figcaption { | ||
| font-size: 13px; color: var(--ink-2); margin-top: 10px; text-align: center; | ||
| } | ||
| /* compare table */ | ||
| .table { | ||
| width: 100%; border-collapse: collapse; margin: 12px 0 6px; | ||
| background: var(--paper); border: 1px solid var(--line); border-radius: 12px; overflow: hidden; | ||
| box-shadow: var(--shadow); | ||
| } | ||
| .table th, .table td { | ||
| text-align: left; padding: 12px 14px; border-bottom: 1px solid var(--line); | ||
| vertical-align: top; font-size: 14.5px; | ||
| } | ||
| .table th { background: #efeadb; font-weight: 700; } | ||
| .table tr:last-child td { border-bottom: 0; } | ||
| .table .pick { background: var(--accent-soft); } | ||
| .table .pick td:first-child { font-weight: 700; color: var(--accent); } | ||
| /* risk table */ | ||
| .risk th, .risk td { font-size: 14px; } | ||
| .lv-h { color: var(--warn); font-weight: 700; } | ||
| .lv-m { color: #d68c1a; font-weight: 700; } | ||
| .lv-l { color: var(--accent); font-weight: 700; } | ||
| /* code */ | ||
| pre { | ||
| margin: 0; padding: 16px 18px; | ||
| background: #1f2226; color: #e8e6df; border-radius: 10px; | ||
| font-family: var(--mono); font-size: 13px; line-height: 1.65; | ||
| overflow-x: auto; | ||
| } | ||
| pre .c { color: #8a8f78; } | ||
| pre .k { color: #6ec5a8; } | ||
| pre .s { color: #f4d35e; } | ||
| pre .n { color: #ec9c8c; } | ||
| code.inline { | ||
| font-family: var(--mono); font-size: .92em; | ||
| background: #efeadb; padding: 1px 6px; border-radius: 4px; | ||
| } | ||
| /* callouts */ | ||
| .callout { | ||
| margin: 18px 0; | ||
| padding: 16px 18px; | ||
| border-left: 4px solid var(--accent); | ||
| background: var(--accent-soft); | ||
| border-radius: 0 10px 10px 0; | ||
| } | ||
| .callout.warn { border-color: var(--warn); background: var(--warn-soft); } | ||
| .callout p { margin: 0; } | ||
| .callout strong { color: var(--accent); } | ||
| .callout.warn strong { color: var(--warn); } | ||
| /* quote */ | ||
| .quote { | ||
| border-left: 3px solid var(--line); padding: 6px 16px; | ||
| color: var(--ink-2); font-size: 14.5px; margin: 18px 0; | ||
| } | ||
| /* badges */ | ||
| .badge { | ||
| display: inline-block; font-size: 12px; padding: 2px 8px; border-radius: 999px; | ||
| background: var(--accent); color: #fff; font-weight: 700; letter-spacing: .04em; | ||
| vertical-align: 2px; margin-left: 6px; | ||
| } | ||
| /* conclusion */ | ||
| .conclusion { | ||
| margin-top: 56px; padding: 28px 28px 24px; | ||
| background: var(--ink); color: #f6f3e8; border-radius: 16px; | ||
| } | ||
| .conclusion h2 { color: var(--yellow); border: 0; padding: 0; margin: 0 0 12px; } | ||
| .conclusion ol { margin: 0; padding-left: 20px; } | ||
| .conclusion li { margin: 6px 0; } | ||
| .conclusion code.inline { background: #2c2f33; color: #f6f3e8; } | ||
| footer { margin-top: 36px; color: var(--ink-2); font-size: 13px; } | ||
| @media (max-width: 720px) { | ||
| .wrap { padding: 32px 18px 64px; } | ||
| .hero h1 { font-size: 30px; } | ||
| section > h2 { font-size: 22px; } | ||
| } | ||
| </style> | ||
| </head> | ||
| <body> | ||
| <div class="wrap"> | ||
| <header class="hero"> | ||
| <span class="kicker">{{KICKER}}</span> | ||
| <h1>{{H1}}</h1> | ||
| <p class="lede">{{LEDE}}</p> | ||
| <div class="tldr"> | ||
| <h2>TL;DR</h2> | ||
| <ol> | ||
| <!-- tldr li 群 --> | ||
| </ol> | ||
| </div> | ||
| </header> | ||
| <!-- sections (Phase 4 で outline.sections を順に展開) | ||
| <section> | ||
| <h2><span class="num">{{NUM}}</span>{{TITLE}}</h2> | ||
| <p class="sub">{{SUB}}</p> | ||
| <figure> | ||
| <img src="images/concept-{{NN}}-01.png" alt="{{ALT}}"> | ||
| <figcaption>{{CAPTION}}</figcaption> | ||
| </figure> | ||
| {{BLOCKS}} | ||
| </section> | ||
| --> | ||
| <div class="conclusion"> | ||
| <h2>結論</h2> | ||
| <ol> | ||
| <!-- conclusion li 群 --> | ||
| </ol> | ||
| </div> | ||
| <footer>{{FOOTER}}</footer> | ||
| </div> | ||
| </body> | ||
| </html> |
+253
-6
| #!/usr/bin/env node | ||
| // src/index.ts | ||
| import { readFileSync } from "fs"; | ||
| import path3 from "path"; | ||
| import { fileURLToPath } from "url"; | ||
| import { Command } from "commander"; | ||
@@ -112,2 +115,72 @@ | ||
| // src/lib/preflight.ts | ||
| var LEAK_PATTERNS = [ | ||
| // macOS 個人 home (作者マシン露呈の代表例) | ||
| { name: "macos_user", re: /\/Users\/[A-Za-z0-9._-]+\/[^\s"'<>)]*/g, severity: "error" }, | ||
| // Linux 個人 home (システム/コンテナの場合あるが ~/ より特定性高い) | ||
| { name: "linux_home", re: /\/home\/[A-Za-z0-9._-]+\/[^\s"'<>)]*/g, severity: "error" }, | ||
| // file:// URI (本文中・属性中問わず leak の確定) | ||
| { name: "file_uri", re: /\bfile:\/\/[^\s"'<>)]+/gi, severity: "error" }, | ||
| // Windows 個人 path (Users/Documents/Desktop directly under drive letter) | ||
| { name: "windows_user", re: /\b[A-Za-z]:\\(?:Users|Documents|Desktop)\\[^\s"'<>)]+/g, severity: "error" }, | ||
| // macOS tmpdir (TMPDIR の typical layout) | ||
| { name: "macos_tmp", re: /\/(?:private\/)?var\/folders\/[A-Za-z0-9_]{1,3}\/[A-Za-z0-9._+/-]+/g, severity: "error" }, | ||
| // ~/ 展開 (本文中によく出るが、PATH っぽく続いてるものは warn) | ||
| { name: "home_tilde", re: /(?<=^|[\s"'(>])~\/[A-Za-z0-9._-][^\s"'<>)]*/g, severity: "warn" } | ||
| ]; | ||
| function scanLocalPathLeaks(html) { | ||
| const lineStartOffsets = [0]; | ||
| for (let i = 0; i < html.length; i++) { | ||
| if (html.charCodeAt(i) === 10) lineStartOffsets.push(i + 1); | ||
| } | ||
| const lineForOffset = (offset) => { | ||
| let lo = 0; | ||
| let hi = lineStartOffsets.length - 1; | ||
| while (lo < hi) { | ||
| const mid = lo + hi + 1 >>> 1; | ||
| if (lineStartOffsets[mid] <= offset) lo = mid; | ||
| else hi = mid - 1; | ||
| } | ||
| return lo + 1; | ||
| }; | ||
| const lineText = (lineNum) => { | ||
| const start = lineStartOffsets[lineNum - 1] ?? 0; | ||
| const end = lineStartOffsets[lineNum] ?? html.length; | ||
| return html.slice(start, end).replace(/\r?\n$/, ""); | ||
| }; | ||
| const hits = []; | ||
| for (const { name, re, severity } of LEAK_PATTERNS) { | ||
| re.lastIndex = 0; | ||
| let m; | ||
| while ((m = re.exec(html)) !== null) { | ||
| const line = lineForOffset(m.index); | ||
| const excerpt = lineText(line).trim().slice(0, 120); | ||
| hits.push({ pattern: name, severity, match: m[0], line, excerpt }); | ||
| if (m.index === re.lastIndex) re.lastIndex++; | ||
| } | ||
| } | ||
| hits.sort((a, b) => a.line - b.line || a.pattern.localeCompare(b.pattern)); | ||
| return hits; | ||
| } | ||
| function formatLeakReport(htmlPath, hits) { | ||
| const lines = []; | ||
| lines.push(`Found ${hits.length} local-only path reference(s) in HTML:`); | ||
| lines.push(""); | ||
| lines.push(`HTML file: ${htmlPath}`); | ||
| lines.push(""); | ||
| for (const h of hits) { | ||
| lines.push(` line ${String(h.line).padStart(4)} [${h.pattern}] ${h.match}`); | ||
| lines.push(` ${h.excerpt}`); | ||
| } | ||
| lines.push(""); | ||
| lines.push("These paths only work on the author's machine and will appear broken to viewers."); | ||
| lines.push("Fix options:"); | ||
| lines.push(" - Remove the absolute path (most citations don't need the full path)"); | ||
| lines.push(" - Replace with a public URL"); | ||
| lines.push(" - Strip the leaked filename and refer to it abstractly"); | ||
| lines.push(""); | ||
| lines.push("To bypass this check (NOT recommended), set ZUROKU_SKIP_PREFLIGHT=1."); | ||
| return lines.join("\n"); | ||
| } | ||
| // src/commands/publish.ts | ||
@@ -245,2 +318,12 @@ var HTML_MAX_BYTES = 5 * 1024 * 1024; | ||
| if (process.env.ZUROKU_SKIP_PREFLIGHT !== "1") { | ||
| const leaks = scanLocalPathLeaks(htmlText); | ||
| for (const w of leaks.filter((l) => l.severity === "warn")) { | ||
| warn(`local-path hint at line ${w.line} [${w.pattern}]: ${w.match}`); | ||
| } | ||
| const errors = leaks.filter((l) => l.severity === "error"); | ||
| if (errors.length > 0) { | ||
| throw new ZurokuError3("LOCAL_PATH_LEAK", 0, formatLeakReport(htmlPath, errors)); | ||
| } | ||
| } | ||
| if (process.env.ZUROKU_SKIP_PREFLIGHT !== "1") { | ||
| const { references, expectedFilenames } = extractHtmlAssetRefs(htmlText); | ||
@@ -310,2 +393,160 @@ const providedFilenames = assets.map((a) => a.filename); | ||
| // src/commands/update.ts | ||
| import { promises as fs2 } from "fs"; | ||
| import path2 from "path"; | ||
| import { | ||
| compressForUpload as compressForUpload2, | ||
| passthroughForUpload as passthroughForUpload2, | ||
| ZurokuError as ZurokuError4 | ||
| } from "@zuroku/core"; | ||
| var HTML_MAX_BYTES2 = 5 * 1024 * 1024; | ||
| var ULID_RE = /^[0-9A-HJKMNP-TV-Z]{26}$/i; | ||
| var UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; | ||
| function looksLikeId(s) { | ||
| return ULID_RE.test(s) || UUID_RE.test(s); | ||
| } | ||
| async function resolveProjectId(client, slugOrId) { | ||
| if (looksLikeId(slugOrId)) return slugOrId; | ||
| const projects = await client.list(); | ||
| const hit = projects.find((p) => p.slug === slugOrId || p.id === slugOrId); | ||
| if (!hit) { | ||
| throw new ZurokuError4("NOT_FOUND", 404, `No project found for slug or id: ${slugOrId}`); | ||
| } | ||
| return hit.id; | ||
| } | ||
| async function callRepublishApi(config, pathname, body, extraHeaders) { | ||
| const base = config.base_url.replace(/\/+$/, ""); | ||
| const res = await fetch(`${base}${pathname}`, { | ||
| method: "POST", | ||
| headers: { | ||
| Accept: "application/json", | ||
| "Content-Type": "application/json", | ||
| Authorization: `Bearer ${config.token}`, | ||
| ...extraHeaders | ||
| }, | ||
| body: JSON.stringify(body) | ||
| }); | ||
| if (!res.ok) { | ||
| let detail = ""; | ||
| try { | ||
| const j = await res.json(); | ||
| const code = j.error?.code ?? "HTTP_ERROR"; | ||
| const msg = j.error?.message ?? res.statusText; | ||
| throw new ZurokuError4(code, res.status, `${msg} (${pathname})`); | ||
| } catch (e) { | ||
| if (e instanceof ZurokuError4) throw e; | ||
| detail = await res.text().catch(() => ""); | ||
| throw new ZurokuError4("HTTP_ERROR", res.status, `${res.statusText} (${pathname}) ${detail}`); | ||
| } | ||
| } | ||
| return await res.json(); | ||
| } | ||
| function registerUpdateCommand(parent) { | ||
| parent.command("update").description("Republish an existing project, keeping its slug/URL (HTML + asset replace)").argument("<slug-or-id>", "Existing project slug or id (URL part after /p/)").argument("<html>", "Path to the new HTML file (<= 5 MiB)").argument("[images...]", "Image files to upload as the new asset set (full replacement)").option("--no-compress", "Skip image compression (upload originals)").option("-u, --base-url <url>", "Override API base URL").action(async (slugOrId, htmlArg, images, opts) => { | ||
| try { | ||
| const config = await loadRuntimeConfig({ ...opts.baseUrl ? { baseUrl: opts.baseUrl } : {} }); | ||
| const htmlPath = path2.resolve(htmlArg); | ||
| let htmlStat; | ||
| try { | ||
| htmlStat = await fs2.stat(htmlPath); | ||
| } catch { | ||
| throw new ZurokuError4("NOT_FOUND", 0, `HTML file not found: ${htmlArg}`); | ||
| } | ||
| if (!htmlStat.isFile()) { | ||
| throw new ZurokuError4("INVALID_INPUT", 0, `Not a regular file: ${htmlArg}`); | ||
| } | ||
| if (htmlStat.size > HTML_MAX_BYTES2) { | ||
| throw new ZurokuError4( | ||
| "PAYLOAD_TOO_LARGE", | ||
| 413, | ||
| `HTML file exceeds 5 MiB limit (${htmlStat.size} bytes)` | ||
| ); | ||
| } | ||
| const htmlBuf = await fs2.readFile(htmlPath); | ||
| info(`html: ${path2.basename(htmlPath)} (${htmlStat.size} bytes)`); | ||
| const assets = []; | ||
| const renameMap = []; | ||
| for (const img of images) { | ||
| const abs = path2.resolve(img); | ||
| const label = path2.basename(abs); | ||
| const payload = opts.compress ? await compressForUpload2(abs) : await passthroughForUpload2(abs); | ||
| info( | ||
| `asset: ${label} -> ${payload.filename} (${payload.buffer.byteLength} bytes, ${payload.contentType})` | ||
| ); | ||
| if (label !== payload.filename) { | ||
| renameMap.push({ from: label, to: payload.filename }); | ||
| } | ||
| assets.push({ | ||
| filename: payload.filename, | ||
| buffer: payload.buffer, | ||
| contentType: payload.contentType | ||
| }); | ||
| } | ||
| const htmlOriginal = htmlBuf.toString("utf8"); | ||
| const htmlText = renameMap.length > 0 ? rewriteHtmlForRename(htmlOriginal, renameMap) : htmlOriginal; | ||
| if (renameMap.length > 0 && htmlText !== htmlOriginal) { | ||
| info(`html: rewrote <img src> references for ${renameMap.length} compressed asset(s)`); | ||
| } | ||
| const htmlForUpload = htmlText !== htmlOriginal ? Buffer.from(htmlText, "utf8") : htmlBuf; | ||
| if (process.env.ZUROKU_SKIP_PREFLIGHT !== "1") { | ||
| const leaks = scanLocalPathLeaks(htmlText); | ||
| for (const w of leaks.filter((l) => l.severity === "warn")) { | ||
| warn(`local-path hint at line ${w.line} [${w.pattern}]: ${w.match}`); | ||
| } | ||
| const errors = leaks.filter((l) => l.severity === "error"); | ||
| if (errors.length > 0) { | ||
| throw new ZurokuError4("LOCAL_PATH_LEAK", 0, formatLeakReport(htmlPath, errors)); | ||
| } | ||
| } | ||
| if (process.env.ZUROKU_SKIP_PREFLIGHT !== "1") { | ||
| const { references, expectedFilenames } = extractHtmlAssetRefs(htmlText); | ||
| const providedFilenames = assets.map((a) => a.filename); | ||
| const provided = new Set(providedFilenames); | ||
| const missing = [...expectedFilenames].filter((f) => !provided.has(f)); | ||
| const nonImg = references.filter( | ||
| (r) => !/^(?:\.\/)?img\//.test(r) && !/^\/?(style|css|js|favicon)/i.test(r) && !/^[a-z]+:/i.test(r) | ||
| ); | ||
| const hasImgTag = /<img\b[^>]*src=/i.test(htmlText); | ||
| const layoutBroken = hasImgTag && expectedFilenames.size === 0 && nonImg.length > 0; | ||
| if (missing.length > 0 || layoutBroken) { | ||
| throw new ZurokuError4( | ||
| "INVALID_INPUT", | ||
| 0, | ||
| preflightErrorMessage(htmlPath, references, expectedFilenames, providedFilenames, opts.compress) | ||
| ); | ||
| } | ||
| } | ||
| const client = makeClient(config); | ||
| info(`resolving "${slugOrId}"...`); | ||
| const projectId = await resolveProjectId(client, slugOrId); | ||
| info(`project_id=${projectId}`); | ||
| info(`republish-init: declaring ${assets.length} asset(s)...`); | ||
| const init = await callRepublishApi( | ||
| config, | ||
| `/api/projects/${encodeURIComponent(projectId)}/republish-init`, | ||
| { asset_filenames: assets.map((a) => a.filename) } | ||
| ); | ||
| info("uploading html + assets..."); | ||
| await Promise.all([ | ||
| client.uploadHtml(projectId, init.upload_token, htmlForUpload), | ||
| ...assets.map( | ||
| (a) => client.uploadAsset(projectId, init.upload_token, a.filename, a.buffer, a.contentType) | ||
| ) | ||
| ]); | ||
| info("republish: swapping manifest..."); | ||
| const res = await callRepublishApi( | ||
| config, | ||
| `/api/projects/${encodeURIComponent(projectId)}/republish`, | ||
| {}, | ||
| { "X-Upload-Token": init.upload_token } | ||
| ); | ||
| success(`updated: slug=${res.slug} id=${res.id}`); | ||
| process.stdout.write(`${res.url} | ||
| `); | ||
| } catch (e) { | ||
| fatal(e); | ||
| } | ||
| }); | ||
| } | ||
| // src/commands/list.ts | ||
@@ -443,3 +684,3 @@ function pad(s, n) { | ||
| saveConfig as saveConfig2, | ||
| ZurokuError as ZurokuError4 | ||
| ZurokuError as ZurokuError5 | ||
| } from "@zuroku/core"; | ||
@@ -449,3 +690,3 @@ var KEYS = ["default-visibility"]; | ||
| if (KEYS.includes(raw)) return raw; | ||
| throw new ZurokuError4( | ||
| throw new ZurokuError5( | ||
| "INVALID_INPUT", | ||
@@ -459,3 +700,3 @@ 0, | ||
| if (raw === "public") { | ||
| throw new ZurokuError4( | ||
| throw new ZurokuError5( | ||
| "INVALID_INPUT", | ||
@@ -466,3 +707,3 @@ 0, | ||
| } | ||
| throw new ZurokuError4( | ||
| throw new ZurokuError5( | ||
| "INVALID_INPUT", | ||
@@ -477,3 +718,3 @@ 0, | ||
| } catch (e) { | ||
| throw new ZurokuError4( | ||
| throw new ZurokuError5( | ||
| "CONFIG", | ||
@@ -537,3 +778,8 @@ 0, | ||
| // src/index.ts | ||
| var VERSION = "0.1.0"; | ||
| function readPackageVersion() { | ||
| const pkgPath = path3.resolve(path3.dirname(fileURLToPath(import.meta.url)), "..", "package.json"); | ||
| const pkg = JSON.parse(readFileSync(pkgPath, "utf8")); | ||
| return typeof pkg.version === "string" ? pkg.version : "0.0.0"; | ||
| } | ||
| var VERSION = readPackageVersion(); | ||
| var program = new Command(); | ||
@@ -543,2 +789,3 @@ program.name("zuroku").description("zuroku \u2014 AI-driven graphic-recording publish tool").version(VERSION, "-v, --version", "print version"); | ||
| registerPublishCommand(program); | ||
| registerUpdateCommand(program); | ||
| registerListCommand(program); | ||
@@ -545,0 +792,0 @@ registerDeleteCommand(program); |
+9
-8
| { | ||
| "name": "@zuroku/cli", | ||
| "version": "0.1.1", | ||
| "version": "0.1.3", | ||
| "type": "module", | ||
@@ -37,2 +37,9 @@ "description": "Command-line publisher for zuroku — upload AI-generated graphic-recording HTML + assets to your zuroku instance.", | ||
| }, | ||
| "scripts": { | ||
| "build": "tsup", | ||
| "dev": "tsup --watch", | ||
| "test": "vitest run", | ||
| "typecheck": "tsc --noEmit", | ||
| "prepublishOnly": "pnpm run typecheck && pnpm run test && pnpm run build" | ||
| }, | ||
| "dependencies": { | ||
@@ -48,9 +55,3 @@ "@zuroku/core": "^0.1.0", | ||
| "vitest": "^2.1.9" | ||
| }, | ||
| "scripts": { | ||
| "build": "tsup", | ||
| "dev": "tsup --watch", | ||
| "test": "vitest run", | ||
| "typecheck": "tsc --noEmit" | ||
| } | ||
| } | ||
| } |
@@ -7,3 +7,3 @@ --- | ||
| author: AI-Driven-R-D-Dept | ||
| version: '0.1.1' | ||
| version: '0.1.2' | ||
| user-invocable: true | ||
@@ -10,0 +10,0 @@ argument-hint: <html-path> [image-paths...] --title "..." [--no-compress] [--visibility private|curator] [--private] |
Sorry, the diff of this file is too big to display
Network access
Supply chain riskThis module accesses the network.
Found 1 instance in 1 package
Filesystem access
Supply chain riskAccesses the file system, and could potentially read sensitive data.
Found 1 instance in 1 package
134443
70.57%9
50%778
46.24%7
250%1
Infinity%