SLOPSHOPPER

x-post

/x と /x-reply で登録ハッシュタグ入りのXの投稿画面を開く。右ペインに登録タグとメンションを出す。推論中だけ投稿できるモードつき

newpanecommandtoastprocess
★ 1v0.3.0MITupdated 2026-10-15marulimo/claude-code-mods/mods/x-post
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · x-post
│ ┃ X ✕ › fix the failing auth test and add an audit log call │ ┃ ハッシュタグ │ ┃ なし(下の欄で追加) ⏺ Read(src/auth.ts) │ ┃ タグを足す(空白で複数) ⏎ 追加 ⎿ Read 6 lines │ ┃ ⏺ Update(src/auth.ts) │ ┃ ⎿ Added 2 lines, removed 1 line │ ┃ メンション ⏺ Bash(bun test) │ ┃ /x-account @名前 で設定 ⎿ 3 pass, 1 fail │ ┃ [ 更新 ] │ ┃ ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ 投稿 │ ┃ いつでも投稿できます ✻ Worked for 42s · done 4:20 PM │ ┃ [ 推論中だけ オフ ] │ ┃ › /x │ ┃ ⎿ x-post: 使い方: /x 投稿したい本文 │ ┃ 返信先(返信のときだけ) │ ┃ ポストのURL ⏎ 決定 │ ┃ 本文(Enterで投稿画面へ) │ ┃ ここに書く ⏎ 投稿画面へ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · X
ハッシュタグ なし(下の欄で追加) タグを足す(空白で複数) ⏎ 追加 メンション /x-account @名前 で設定 [ 更新 ] 投稿 いつでも投稿できます [ 推論中だけ オフ ] 返信先(返信のときだけ) ポストのURL ⏎ 決定 本文(Enterで投稿画面へ) ここに書く ⏎ 投稿画面へ
README

Claude Code Mods

Claude Code の Mod(10/1 公開の拡張機能)6本をまとめたリポジトリです。

インストール

ターミナルの Claude Code で、<mod> を下記の名前に置き換えて実行します。

/plugin install <mod> --marketplace marulimo/claude-code-mods

続けて y を入力し、インストール先のスコープを選びます。デスクトップアプリの Code タブではこのコマンドは使えないため、ターミナルでインストールしてください。user スコープに入れれば、デスクトップのセッションでも読み込まれます。

Mod の設定項目は各 mods/<mod>/.claude-plugin/plugin.json の userConfig に定義しています。実行コマンドは PATH 上で見つかる必要があります。別の場所にある場合は設定で実行ファイルのパスを指定してください。

ask-codex

右側のパネルから、Devin SWE-2 への相談と Codex への作業依頼を送ります。送った分は Devin と Codex の利用枠で動き、Claude の使用量は使いません。単独のリポジトリ marulimo/ask-codex でも同じ版を公開しています。

  • 使い方: パネル上部の「Devin(相談)」「Codex(作業)」で送り先を選び、入力欄に書いて Enter。「推論」で強さを選びます(送り先ごとに覚えます)。コマンドでも送れます: /devin <質問>、/codex <依頼>、/codex-panel。
  • 必要なもの: 使う送り先の CLI です(片方だけでも動きます)。Codex CLI はログイン済み、Devin CLI はログイン済みで SWE-2 が使えること。
  • 設定: Codex の権限(workspace-write)、Codex の推論強度(high)、Codex のモデル(空なら CLI の既定)、codex の場所(空なら自動で探す)、Devin の強さ(high)、devin の場所(空なら自動で探す)、Devin を動かすフォルダー(空なら ~/.ask-codex/devin-chat)です。
  • 注意: Codex の権限を danger-full-access にすると、確認もサンドボックスもなしで作業フォルダーを書き換えます。Devin は中身の空な専用フォルダーで、その1回だけフォルダーの信頼確認を省いて動かします。失敗の記録は ~/.ask-codex/errors.log に残ります。
  • 詳しくは mods/ask-codex/README.md を参照してください。

ai-usage-hub

Claude、Codex、Grok、Gemini、Devin の使用率とリセット時刻をパネルに表示します。Claude のセッション情報と外部 collector の結果を使います。

  • コマンド: /usage-hub。起動時にもパネルを開き、5分おきに取得します。
  • 必要なもの: Node.js と、各サービスの使用率を集める collector が別途必要です。collector とそのログイン処理はこのリポジトリには含まれません。
  • 設定: userConfig はありません。環境変数 AI_USAGE_NODE(既定 node)と AI_USAGE_DASH_DIR(既定は USERPROFILE または HOME の下の ai-usage-dashboard)を使います。
  • 呼び出し: collector のフォルダーを作業ディレクトリとして、次を実行します。終了コード 0 で、標準出力に JSON だけを返してください。
node <AI_USAGE_DASH_DIR>/server.mjs --json codex,grok,gemini,devin,claude

JSON は次の形です。groups は必須配列で、グループには文字列の name と配列の rows が必要です。行には文字列の label と有限の数値または null の used が必要です。used は使用率の百分率です。

{
  "groups": [
    {
      "name": "CODEX",
      "rows": [
        { "label": "5時間", "used": 25, "reset": "2026-10-16T00:00:00Z" },
        { "label": "週次", "used": null, "state": "データなし" }
      ],
      "stale": false,
      "note": null
    }
  ]
}

認識するグループ名は CLAUDE、CODEX、GROK、GEMINI、DEVIN です。省略可能な項目は、行の reset(日時文字列)、state(状態文字列)、star(真なら Claude の Fable 枠)、グループの stale(真なら古いデータ)、note(文字列)です。Claude の補完行は 5時間 と 週次 で始まるラベルを使います。取得に失敗したときは前回値を残すため、表示の失敗時刻も確認してください。

ai-router

入力を送信すると、依頼に適した AI の判定結果をトーストとステータス行に表示します。Claude への送信は判定の完了を待ちません。

  • コマンド: ありません。通常の入力送信時に動きます。スラッシュコマンド、空白を除いて8文字未満の入力などは判定しません。
  • 必要なもの: Python と外部の判定スクリプトです。スクリプト、利用先 API の設定や認証は同梱しません。
  • 設定: pythonPath(python)、routerScript(空)、backend(jev、または decisions)、privateFolders(空)、privateWords(空)です。
  • routerScript が空なら実行せず、「判定スクリプト未設定」と表示します。
  • privateFolders は除外する絶対パス、privateWords は除外する語句をカンマ区切りで指定します。フォルダー自身とその配下、語句の部分一致を大文字小文字を区別せず判定します。既定ではどちらも除外しません。
  • 注意: 設定すると入力全文を判定スクリプトの標準入力へ渡します。外部送信の有無はスクリプト側の実装にも依存します。

呼び出しは次の形です。入力全文を標準入力から読み、12秒以内に終了コード 0 と標準出力の JSON を返す必要があります。

python <routerScript> --backend jev

backend を decisions にすると末尾の引数も decisions になります。成功結果の例です。

{
  "ok": true,
  "label": "候補A",
  "entry": "作業の入口",
  "top": [{ "id": "candidate_a", "p": 0.82 }, { "id": "candidate_b", "p": 0.11 }]
}

成功時は ok: true、文字列の label と entry、1件以上の top が必要です。p は 0〜1 の有限数なら百分率で表示します。2件目の id と p が有効なら次点も表示します。失敗時は {"ok":false,"reason":"service_unavailable"} の形を返せます。reason は英字で始まる英数字・アンダースコア80文字以内だけを表示します。それ以外は一般的な失敗表示になります。

side-chat

Claude が考えている間、Dots 役(ChatGPT)と GrokBot 役(Grok)がパネルで交互に、いまの会話の話題について雑談します。直近の会話(最大8発言・1発言300字・合計1500字)、このターンの使用ツール名、直近の雑談を送り先に渡します。

  • 送らないもの: ツールの出力とコードブロックは渡しません。設定 privateWords の語句を含む発言は丸ごと省き、APIキー・トークン・メールアドレス・32文字以上の英数字列は伏せ字にします。
  • コマンド: /side-chat、/side-chat off、/side-chat on です。
  • 必要なもの: ログイン済みの Codex CLI と、hermes_cli を読み込める Python / Hermes Agent です。Grok は xai-oauth 経由で X Premium の枠を使います。
  • 設定: codexPath(codex)、gptModel(gpt-6.1-sol)、python(py)、pythonVersion(-3.13)、grokModel(grok-4.3)、startDelaySec(20秒)、gapSec(20秒)、maxLines(6)、privateFolders(空)、privateWords(空)です。Python ランチャーに版指定が不要なら pythonVersion を空にします。
  • privateFolders は除外する絶対パスをカンマ区切りで指定します。設定したフォルダーとその配下では送りません。既定では除外しません。
  • privateWords は雑談の材料に渡さない語句をカンマ区切りで指定します。大文字小文字を区別せず部分一致で判定します。既定では除外しません。
  • 注意: 雑談ごとに Codex / Grok の利用枠を使います。不要な場合は off で止めてください。

tag-feed

X のハッシュタグに合う投稿を探し、パネルに新着を表示します。タグを登録すると定期取得します。

  • コマンド: /tag-feed #タグ #別のタグ、/tag-feed です。パネルには即時取得と停止・再開のボタンがあります。
  • 必要なもの: hermes_cli を読み込める Python / Hermes Agent と、Grok の xai-oauth ログインです。
  • 設定: python(py)、pythonVersion(-3.13)、model(grok-4.3)、intervalMinutes(10分、最短2分)です。
  • 注意: X API は使いません。Grok の検索で投稿 URL を探し、X の埋め込み用の公開エンドポイントから本文を取得します。取得ごとに Grok の X Premium 枠を使います。検索結果や公開エンドポイントの状況により取得できない投稿もあります。

x-post

本文に登録タグを付け、ブラウザで X の投稿画面や返信画面を開きます。最後の「ポスト」は人が押します。この Mod が自分で投稿することはありません。

  • コマンド: /x 本文、/x-reply <投稿URL> 本文、/x-tag [add|rm|clear] [タグ]、/x-mode on|off、/x-account @名前、/x-pane です。
  • ペイン: 上にハッシュタグ(入力欄で追加、押すと削除)とメンション、下に投稿欄(返信先と本文。Enter で投稿画面を開く)を並べます。本文はハッシュタグ込みのプレビューと文字数の目安を出します。
  • 必要なもの: X にログインしたブラウザです。メンション検索には別途、PATH 上の Hermes Agent(hermes)と Grok のログインが必要です。hermes が無くても投稿はできます。
  • 設定: userConfig はありません。タグ、推論中だけ投稿可能にするモード、自分のアカウントを上記コマンドで保存します。アカウントの既定値は空で、未設定ならペインに /x-account @名前 で設定 と出ます。hermes のモデルとプロバイダーは環境変数 X_POST_HERMES_MODEL(既定 grok-4.3)と X_POST_HERMES_PROVIDER(既定 xai-oauth)で変えられます。
  • 上限: タグは10個、1つ50文字までです。本文がタグ込みで280を超えると投稿画面を開きません(X に合わせ、日本語などは1文字2、URL は23と数える目安です)。
  • 注意: X API キーは不要です。メンション検索は「更新」を押したときだけ動き、1回30〜40秒かかり Grok の枠を使います。検索なので取りこぼしがあり、X の通知画面の代わりにはなりません。ブラウザは Windows では rundll32、macOS では open、それ以外では xdg-open で開きます。開けなければコマンドが返すリンクから開いてください。デスクトップ版ではペインの幅を Mod から変えられないので、狭いときは境目をドラッグしてください。

動作環境とライセンス

作者は Windows 11 で動かしています。他 OS は未確認です。Mod 対応の Claude Code が必要です。

MIT ライセンスです。詳しくは LICENSE を参照してください。

Source 2 files
hooks/register.tsx 497 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { Mention, Mentions } from '../types'
5
6const PANE = 'x-post'
7// 横に並ぶときは columns、プロンプトの上に出るときは rows が効く。手で広げた大きさのほうが優先される
8const PANE_OPEN = { id: PANE, title: 'X', columns: 100, rows: 40 }
9const TAGS_KEY = 'hashtags'
10const GATE_KEY = 'gated'
11const ACCOUNT_KEY = 'account'
12const MAX_TAGS = 10
13const MAX_TAG_LENGTH = 50
14const MAX_WEIGHT = 280
15
16const isThinking = atom({ plugin: 'x-post', key: 'isThinking' } as const, false)
17const isGated = atom({ plugin: 'x-post', key: 'isGated' } as const, false)
18const tags = atom({ plugin: 'x-post', key: 'tags' } as const, [])
19const account = atom({ plugin: 'x-post', key: 'account' } as const, '')
20const mentions = atom({ plugin: 'x-post', key: 'mentions' } as const, { status: 'idle', items: [] })
21const draft = atom({ plugin: 'x-post', key: 'draft' } as const, '')
22const replyTo = atom({ plugin: 'x-post', key: 'replyTo' } as const, '')
23const lastResult = atom({ plugin: 'x-post', key: 'lastResult' } as const, '')
24const tagDraft = atom({ plugin: 'x-post', key: 'tagDraft' } as const, '')
25const tagNote = atom({ plugin: 'x-post', key: 'tagNote' } as const, '')
26
27const USAGE_TAG = [
28  '/x-tag            登録済みの一覧',
29  '/x-tag add 朝活 AI  登録(# はあってもなくてもよい)',
30  '/x-tag rm 朝活      削除',
31  '/x-tag clear       全部削除',
32].join('\n')
33
34// "#朝活" "#朝活" "朝活" を同じ "朝活" にそろえる。空白や記号だけのものは捨てる
35export const normalizeTag = (raw: string): string | undefined => {
36  const tag = raw.trim().replace(/^[##]+/, '')
37  if (!tag || /[\s##]/.test(tag) || tag.length > MAX_TAG_LENGTH) return undefined
38
39  return tag
40}
41
42export const normalizeAccount = (raw: string): string | undefined => {
43  const handle = raw.trim().replace(/^[@@]/, '')
44
45  return /^[A-Za-z0-9_]{1,15}$/.test(handle) ? handle : undefined
46}
47
48// 本文にすでに書いてあるタグは二重に足さない
49export const composeText = (body: string, list: readonly string[]): string => {
50  const missing = list.filter(tag => !body.includes(`#${tag}`) && !body.includes(`#${tag}`))
51  if (missing.length === 0) return body
52
53  return `${body}\n\n${missing.map(tag => `#${tag}`).join(' ')}`
54}
55
56export const postWeight = (text: string): number => {
57  let weight = 0
58  const rest = text.replace(/https?:\/\/\S+/g, () => {
59    weight += 23
60    return ''
61  })
62  for (const ch of rest) {
63    const c = ch.codePointAt(0) ?? 0
64    const isNarrow =
65      c <= 0x10ff || (c >= 0x2000 && c <= 0x200d) || (c >= 0x2010 && c <= 0x201f) || (c >= 0x2032 && c <= 0x2037)
66    weight += isNarrow ? 1 : 2
67  }
68
69  return weight
70}
71
72export const intentUrl = (text: string, replyTo?: string): string =>
73  `https://x.com/intent/post?${replyTo ? `in_reply_to=${replyTo}&` : ''}text=${encodeURIComponent(text)}`
74
75export const statusId = (url: string): string | undefined =>
76  /^https?:\/\/(?:www\.|mobile\.)?(?:x|twitter)\.com\/[^/]+\/status\/(\d+)/.exec(url.trim())?.[1]
77
78// hermes の返答から、投稿URLで裏付けられた行だけを拾う
79export const parseMentions = (out: string): Mention[] =>
80  out
81    .split(/\r?\n/)
82    .map(line => {
83      const url = /https:\/\/(?:x|twitter)\.com\/\S+?\/status\/\d+/.exec(line)?.[0]
84      if (!url) return undefined
85      const text = line
86        .replace(url, '')
87        .replace(/[【】||]/g, ' ')
88        .replace(/^[\s\-*・\d.]+/, '')
89        .replace(/\s+/g, ' ')
90        .trim()
91
92      return { text: text || '(本文なし)', url }
93    })
94    .filter((m): m is Mention => !!m)
95    .slice(0, 20)
96
97const readList = async ($: EngineInterface): Promise<string[]> => {
98  const value = await $.store.get(TAGS_KEY)
99
100  return Array.isArray(value) ? value.filter((v): v is string => typeof v === 'string') : []
101}
102
103const saveTags = async ($: EngineInterface, list: string[]) => {
104  await $.store.set(TAGS_KEY, list)
105  await update($, tags, () => list)
106}
107
108const tagLine = (list: readonly string[]) => list.map(t => `#${t}`).join(' ')
109
110// /x-tag add とペインの欄が共通で使う。返り値は結果の一文
111const addTags = async ($: EngineInterface, words: readonly string[]): Promise<string> => {
112  const added = words.map(normalizeTag).filter((t): t is string => !!t)
113  if (added.length === 0) return `タグにできる文字がありません(空白と # は使えず、${MAX_TAG_LENGTH}文字まで)。`
114  const next = [...new Set([...(await readList($)), ...added])]
115  if (next.length > MAX_TAGS) return `ハッシュタグは ${MAX_TAGS} 個までです。減らしてから足してください。`
116  await saveTags($, next)
117
118  return `登録しました。現在: ${tagLine(next)}`
119}
120
121const removeTags = async ($: EngineInterface, words: readonly string[]): Promise<string> => {
122  const removing = new Set(words.map(normalizeTag).filter((t): t is string => !!t))
123  const next = (await readList($)).filter(t => !removing.has(t))
124  await saveTags($, next)
125
126  return next.length ? `削除しました。現在: ${tagLine(next)}` : '削除しました。登録中のハッシュタグはありません。'
127}
128
129// 推論中だけモードで、いまは投稿させないときの断り文。投稿してよいなら undefined
130const gateRefusal = async ($: EngineInterface): Promise<string | undefined> => {
131  if (!(await read($, isGated))) return undefined
132  if (await read($, isThinking)) return undefined
133
134  return '推論中だけ投稿できるモードです。Claude が考えている間に打ってください(解除は /x-mode off)。'
135}
136
137// URL をいつものブラウザで開くコマンド。Windows、macOS、それ以外(xdg-open)の順に判定する
138const openerFor = async ($: EngineInterface): Promise<string[]> => {
139  try {
140    if ((await $.env.get('OS')) === 'Windows_NT') return ['rundll32', 'url.dll,FileProtocolHandler']
141  } catch {
142    // 環境変数が読めなければ uname で判定する
143  }
144  try {
145    const { stdout } = await $.process.run(['uname', '-s'])
146    if (stdout.trim() === 'Darwin') return ['open']
147  } catch {
148    // uname が無ければ xdg-open に任せる
149  }
150
151  return ['xdg-open']
152}
153
154const openIntent = async ($: EngineInterface, url: string): Promise<boolean> => {
155  // 投稿画面を開くだけで、投稿はしない
156  try {
157    const { exitCode } = await $.process.run([...(await openerFor($)), url])
158
159    return exitCode === 0
160  } catch {
161    return false
162  }
163}
164
165const composeReply = (opened: boolean, text: string, url: string) =>
166  [
167    opened ? 'Xの投稿画面を開きました。内容を確かめて「ポスト」を押してください。' : '下のリンクからXの投稿画面を開いてください。',
168    '',
169    text,
170    '',
171    url,
172  ].join('\n')
173
174type PostOutcome = { isOpened: boolean; message: string; url?: string }
175
176// コマンドとペインの入力欄が共通で使う。replyTo はポストのURL
177const openPost = async ($: EngineInterface, body: string, replyTo?: string): Promise<PostOutcome> => {
178  const refusal = await gateRefusal($)
179  if (refusal) return { isOpened: false, message: refusal }
180  if (!body.trim()) return { isOpened: false, message: '本文が空です。' }
181
182  let id: string | undefined
183  if (replyTo?.trim()) {
184    id = statusId(replyTo)
185    if (!id) return { isOpened: false, message: '返信先がポストのURL(…/status/数字)になっていません。' }
186  }
187
188  const text = composeText(body.trim(), await readList($))
189  const weight = postWeight(text)
190  if (weight > MAX_WEIGHT) {
191    return { isOpened: false, message: `長すぎます(${weight}/${MAX_WEIGHT}。日本語は1文字2と数えます)。` }
192  }
193  const url = intentUrl(text, id)
194  const isOpened = await openIntent($, url)
195
196  return { isOpened, url, message: composeReply(isOpened, text, url) }
197}
198
199const fetchMentions = async ($: EngineInterface) => {
200  const handle = await read($, account)
201  if (!handle) {
202    await update($, mentions, m => ({ ...m, status: 'error' as const, note: '先に /x-account @名前 で自分のアカウントを登録してください。' }))
203    return
204  }
205  await update($, mentions, m => ({ ...m, status: 'loading' as const, note: undefined }))
206
207  const prompt =
208    `Xで『@${handle} 宛てのリプライやメンション(to:${handle} または @${handle} を含む投稿、直近3日)』を検索し、` +
209    'ヒットした投稿を1件1行で【@投稿者ハンドル | 投稿日時 | 本文 | 投稿URL】の形式でリスト化して。' +
210    'あなた自身の要約・一般知識は加えず、実際の検索結果の投稿データだけを出して'
211  try {
212    const { exitCode, stdout, stderr } = await $.process.run(
213      [
214        'hermes',
215        '-z',
216        prompt,
217        '-m',
218        (await $.env.get('X_POST_HERMES_MODEL')) || 'grok-4.3',
219        '--provider',
220        (await $.env.get('X_POST_HERMES_PROVIDER')) || 'xai-oauth',
221      ],
222      { timeoutMs: 240_000 },
223    )
224    const checkedAt = new Date(await $.clock.now()).toLocaleString('ja-JP')
225    if (exitCode !== 0) {
226      await update($, mentions, m => ({ ...m, status: 'error' as const, checkedAt, note: `hermes が失敗しました: ${(stderr || stdout).slice(0, 200)}` }))
227      return
228    }
229    const items = parseMentions(stdout)
230    await update($, mentions, () => ({
231      status: 'done' as const,
232      items,
233      checkedAt,
234      note: items.length ? undefined : '投稿URLつきの結果はありませんでした。',
235    }))
236    if (items.length) $.ui.toast(`Xのメンション ${items.length} 件`)
237  } catch (err) {
238    await update($, mentions, m => ({
239      ...m,
240      status: 'error' as const,
241      note: `メンション検索には hermes(Grok の X 検索)が要ります。README を見てください。(${String(err).slice(0, 120)})`,
242    }))
243  }
244}
245
246export const register: Register = on => {
247  on('session.start', async ($, e, next) => {
248    const commands = [
249      { name: 'x', description: '本文に登録ハッシュタグを付けてXの投稿画面を開く(投稿ボタンは自分で押す)', argumentHint: '本文' },
250      { name: 'x-reply', description: '指定したポストへの返信画面を開く', argumentHint: 'ポストのURL 本文' },
251      { name: 'x-tag', description: '/x で自動で付けるハッシュタグを登録・削除する', argumentHint: 'add|rm|clear タグ' },
252      { name: 'x-mode', description: '推論中だけ投稿できるモードを切り替える', argumentHint: 'on|off' },
253      { name: 'x-account', description: 'メンションを探す自分のXアカウントを登録する', argumentHint: '@名前' },
254      { name: 'x-pane', description: 'Xのペインを右側に開く' },
255    ]
256    for (const command of commands) {
257      // 推論中にも打てるよう、ターンの終わりを待たずに走らせる
258      await $.command.register({ ...command, immediate: true })
259    }
260
261    const saved = await readList($)
262    await update($, tags, () => saved)
263    const gated = (await $.store.get(GATE_KEY)) === true
264    await update($, isGated, () => gated)
265    const handle = await $.store.get(ACCOUNT_KEY)
266    await update($, account, () => (typeof handle === 'string' ? handle : ''))
267
268    void $.ui.open(PANE_OPEN)
269
270    return next(e)
271  })
272
273  on('turn.start', async ($, e, next) => {
274    await update($, isThinking, () => true)
275
276    return next(e)
277  })
278
279  on('turn.complete', async ($, e, next) => {
280    await update($, isThinking, () => false)
281
282    return next(e)
283  })
284
285  on('command.run', { command: 'x-pane' }, async $ => {
286    const opened = await $.ui.open(PANE_OPEN)
287    if (opened.isPlaced) return { text: 'Xのペインを開きました。' }
288
289    // 開いたが、どの画面にも描かれていないとき
290    return { text: `Xのペインは開きましたが、この画面には表示されていません。理由: ${opened.reason}` }
291  })
292
293  on('command.run', { command: 'x-mode' }, async ($, e) => {
294    const arg = e.args.trim()
295    if (arg !== 'on' && arg !== 'off') {
296      const gated = await read($, isGated)
297      return { text: `推論中だけモード: ${gated ? 'オン' : 'オフ'}(切り替えは /x-mode on か /x-mode off)` }
298    }
299    const gated = arg === 'on'
300    await $.store.set(GATE_KEY, gated)
301    await update($, isGated, () => gated)
302
303    return {
304      text: gated
305        ? '推論中だけ投稿できるモードをオンにしました。Claude が考えている間だけ /x と /x-reply が使えます。'
306        : '推論中だけモードをオフにしました。いつでも投稿画面を開けます。',
307    }
308  })
309
310  on('command.run', { command: 'x-account' }, async ($, e) => {
311    const handle = normalizeAccount(e.args)
312    if (!handle) {
313      const current = await read($, account)
314      return { text: current ? `登録中のアカウント: @${current}` : '使い方: /x-account @名前' }
315    }
316    await $.store.set(ACCOUNT_KEY, handle)
317    await update($, account, () => handle)
318
319    return { text: `@${handle} を登録しました。ペインの「更新」でメンションを探します。` }
320  })
321
322  on('command.run', { command: 'x-tag' }, async ($, e) => {
323    const [sub = '', ...rest] = e.args.trim().split(/\s+/).filter(Boolean)
324    const list = await readList($)
325
326    if (sub === '') {
327      return { text: list.length ? `登録中のハッシュタグ: ${tagLine(list)}` : `登録中のハッシュタグはありません。\n${USAGE_TAG}` }
328    }
329
330    if (sub === 'add') {
331      if (rest.length === 0) return { text: `追加するタグを指定してください。
332${USAGE_TAG}` }
333      return { text: await addTags($, rest) }
334    }
335
336    if (sub === 'rm') return { text: await removeTags($, rest) }
337
338    if (sub === 'clear') {
339      await saveTags($, [])
340
341      return { text: 'ハッシュタグを全部削除しました。' }
342    }
343
344    return { text: USAGE_TAG }
345  })
346
347  on('command.run', { command: 'x' }, async ($, e) => {
348    if (!e.args.trim()) return { text: '使い方: /x 投稿したい本文' }
349
350    return { text: (await openPost($, e.args)).message }
351  })
352
353  on('command.run', { command: 'x-reply' }, async ($, e) => {
354    const [target = '', ...words] = e.args.trim().split(/\s+/)
355    const body = words.join(' ')
356    if (!statusId(target) || !body.trim()) return { text: '使い方: /x-reply https://x.com/名前/status/123… 返信の本文' }
357
358    return { text: (await openPost($, body, target)).message }
359  })
360
361  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
362    const table = $.ui.resolve(e)
363    const { Box, Text, Button, Link } = table
364    const Input = 'Input' in table ? table.Input : undefined
365    const gated = await read($, isGated)
366    const thinking = await read($, isThinking)
367    const list = await read($, tags)
368    const handle = await read($, account)
369    const m: Mentions = await read($, mentions)
370    const body = await read($, draft)
371    const target = await read($, replyTo)
372    const result = await read($, lastResult)
373    const tagText = await read($, tagDraft)
374    const tagNoteText = await read($, tagNote)
375    const isBlocked = gated && !thinking
376
377    // 表示幅は30文字前後しか取れないので、どの行も短くする
378    const modeLine = !gated ? 'いつでも投稿できます' : thinking ? '推論中: 投稿できます' : '待機中: 投稿できません'
379
380    const submit = async (value: string) => {
381      const outcome = await openPost($, value, await read($, replyTo))
382      await update($, lastResult, () =>
383        outcome.url ? (outcome.isOpened ? '投稿画面を開きました' : 'ブラウザを開けませんでした') : outcome.message,
384      )
385      if (outcome.url) {
386        await update($, draft, () => '')
387        await update($, replyTo, () => '')
388      }
389    }
390
391    // 投稿欄はペインの下端に寄せる。上はタグとメンション
392    return (
393      <Box flexDirection="column" minHeight={e.props.scroll.bodyRows} justifyContent="space-between">
394        <Box flexDirection="column">
395          <Text bold>ハッシュタグ</Text>
396          {list.length === 0 && <Text dimColor>なし(下の欄で追加)</Text>}
397          {list.length > 0 && (
398            <Box flexDirection="row" flexWrap="wrap" columnGap={1}>
399              {list.map(tag => (
400                <Button key={`tag-${tag}`} plain onPress={async () => {
401                    const note = await removeTags($, [tag])
402                    await update($, tagNote, () => note)
403                  }}
404                >
405                  #{tag} <Text dimColor>×</Text>
406                </Button>
407              ))}
408            </Box>
409          )}
410          {Input && (
411            <Input
412              key="tagAdd"
413              placeholder="タグを足す(空白で複数)"
414              value={tagText}
415              submitLabel="追加"
416              onInput={value => void update($, tagDraft, () => value)}
417              onSubmit={async value => {
418                const note = await addTags($, value.split(/\s+/).filter(Boolean))
419                await update($, tagNote, () => note)
420                if (note.startsWith('登録')) await update($, tagDraft, () => '')
421              }}
422            />
423          )}
424          {tagNoteText && <Text dimColor>{tagNoteText}</Text>}
425          <Text> </Text>
426          <Text bold>メンション</Text>
427          <Text dimColor>{handle ? `@${handle}` : '/x-account @名前 で設定'}</Text>
428          <Box flexDirection="row">
429            <Button
430              key="refresh"
431              label={m.status === 'loading' ? '検索中…' : '更新'}
432              onPress={() => (m.status === 'loading' ? undefined : fetchMentions($))}
433            />
434            {m.checkedAt && <Text dimColor> {m.checkedAt}</Text>}
435          </Box>
436          {m.note && <Text dimColor>{m.note}</Text>}
437          {m.items.map((item, i) => (
438            <Box key={`m${i}`} flexDirection="column">
439              <Text>{item.text}</Text>
440              <Link href={item.url} label="開く" />
441            </Box>
442          ))}
443        </Box>
444        <Box flexDirection="column">
445          <Text> </Text>
446          <Text bold>投稿</Text>
447          <Text color={isBlocked ? 'red' : 'green'}>{modeLine}</Text>
448          <Box flexDirection="row">
449            <Button
450              key="mode"
451              label={gated ? '推論中だけ オン' : '推論中だけ オフ'}
452              onPress={async () => {
453                const nextGated = !(await read($, isGated))
454                await $.store.set(GATE_KEY, nextGated)
455                await update($, isGated, () => nextGated)
456              }}
457            />
458          </Box>
459          {body.trim() && (
460            <Box flexDirection="column">
461              <Text dimColor color={postWeight(composeText(body.trim(), list)) > MAX_WEIGHT ? 'red' : undefined}>
462                プレビュー {postWeight(composeText(body.trim(), list))}/{MAX_WEIGHT}
463              </Text>
464              <Text wrap="wrap">{composeText(body.trim(), list)}</Text>
465            </Box>
466          )}
467          {result && <Text dimColor>{result}</Text>}
468          {Input ? (
469            <Box flexDirection="column">
470              <Text dimColor>返信先(返信のときだけ)</Text>
471              <Input
472                key="replyTo"
473                placeholder="ポストのURL"
474                value={target}
475                submitLabel="決定"
476                onInput={value => void update($, replyTo, () => value)}
477                onSubmit={value => void update($, replyTo, () => value)}
478              />
479              <Text dimColor>本文(Enterで投稿画面へ)</Text>
480              <Input
481                key="body"
482                placeholder={isBlocked ? '考えている間だけ書けます' : 'ここに書く'}
483                value={body}
484                submitLabel="投稿画面へ"
485                onInput={value => void update($, draft, () => value)}
486                onSubmit={value => void submit(value)}
487              />
488            </Box>
489          ) : (
490            <Text dimColor>入力欄なし。/x 本文 で投稿</Text>
491          )}
492        </Box>
493      </Box>
494    )
495  })
496}
497
types/index.d.ts 26 lines
1export type Mention = { text: string; url: string }
2
3export type Mentions = {
4  status: 'idle' | 'loading' | 'done' | 'error'
5  items: Mention[]
6  checkedAt?: string
7  note?: string
8}
9
10declare module 'claude-code' {
11  interface PluginState {
12    'x-post': {
13      isThinking: boolean
14      isGated: boolean
15      tags: string[]
16      account: string
17      mentions: Mentions
18      draft: string
19      replyTo: string
20      lastResult: string
21      tagDraft: string
22      tagNote: string
23    }
24  }
25}
26