Big News: Socket raises $60M Series C at a $1B valuation to secure software supply chains for AI-driven development.Announcement
Sign In

@zuroku/cli

Package Overview
Dependencies
Maintainers
1
Versions
9
Alerts
File Explorer

Advanced tools

Socket logo

Install Socket

Detect and block malicious and high-risk dependencies

Install

@zuroku/cli - npm Package Compare versions

Comparing version
0.1.1
to
0.1.3
+44
skills/run-explainer-page/examples.md
# 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