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

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 上で見つかる必要があります。別の場所にある場合は設定で実行ファイルのパスを指定してください。
右側のパネルから、Devin SWE-2 への相談と Codex への作業依頼を送ります。送った分は Devin と Codex の利用枠で動き、Claude の使用量は使いません。単独のリポジトリ marulimo/ask-codex でも同じ版を公開しています。
/devin <質問>、/codex <依頼>、/codex-panel。workspace-write)、Codex の推論強度(high)、Codex のモデル(空なら CLI の既定)、codex の場所(空なら自動で探す)、Devin の強さ(high)、devin の場所(空なら自動で探す)、Devin を動かすフォルダー(空なら ~/.ask-codex/devin-chat)です。danger-full-access にすると、確認もサンドボックスもなしで作業フォルダーを書き換えます。Devin は中身の空な専用フォルダーで、その1回だけフォルダーの信頼確認を省いて動かします。失敗の記録は ~/.ask-codex/errors.log に残ります。Claude、Codex、Grok、Gemini、Devin の使用率とリセット時刻をパネルに表示します。Claude のセッション情報と外部 collector の結果を使います。
/usage-hub。起動時にもパネルを開き、5分おきに取得します。userConfig はありません。環境変数 AI_USAGE_NODE(既定 node)と AI_USAGE_DASH_DIR(既定は USERPROFILE または HOME の下の ai-usage-dashboard)を使います。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 の判定結果をトーストとステータス行に表示します。Claude への送信は判定の完了を待ちません。
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文字以内だけを表示します。それ以外は一般的な失敗表示になります。
Claude が考えている間、Dots 役(ChatGPT)と GrokBot 役(Grok)がパネルで交互に、いまの会話の話題について雑談します。直近の会話(最大8発言・1発言300字・合計1500字)、このターンの使用ツール名、直近の雑談を送り先に渡します。
privateWords の語句を含む発言は丸ごと省き、APIキー・トークン・メールアドレス・32文字以上の英数字列は伏せ字にします。/side-chat、/side-chat off、/side-chat on です。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 は雑談の材料に渡さない語句をカンマ区切りで指定します。大文字小文字を区別せず部分一致で判定します。既定では除外しません。off で止めてください。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 の投稿画面や返信画面を開きます。最後の「ポスト」は人が押します。この Mod が自分で投稿することはありません。
/x 本文、/x-reply <投稿URL> 本文、/x-tag [add|rm|clear] [タグ]、/x-mode on|off、/x-account @名前、/x-pane です。hermes)と Grok のログインが必要です。hermes が無くても投稿はできます。userConfig はありません。タグ、推論中だけ投稿可能にするモード、自分のアカウントを上記コマンドで保存します。アカウントの既定値は空で、未設定ならペインに /x-account @名前 で設定 と出ます。hermes のモデルとプロバイダーは環境変数 X_POST_HERMES_MODEL(既定 grok-4.3)と X_POST_HERMES_PROVIDER(既定 xai-oauth)で変えられます。rundll32、macOS では open、それ以外では xdg-open で開きます。開けなければコマンドが返すリンクから開いてください。デスクトップ版ではペインの幅を Mod から変えられないので、狭いときは境目をドラッグしてください。作者は Windows 11 で動かしています。他 OS は未確認です。Mod 対応の Claude Code が必要です。
MIT ライセンスです。詳しくは LICENSE を参照してください。
hooks/register.tsx 497 lines1import { 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}
497types/index.d.ts 26 lines1export 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