Chess against Claude in a pane, any time, without taking a chat turn: click a piece and its square, Claude thinks and answers in a few seconds. English and…

A general-use toolkit of Claude Code plugins by jahirtrap. A plugin can bring a mod (a pane next to the chat, a line above the prompt), an MCP server, skills, commands or agents, or several of them together; each one installs on its own. This repository is also their marketplace: add it once, then install only the plugins you want.
[Claude Code] ──marketplace──> [a claude-toolkit plugin: mod, MCP server, skill, command]
Every plugin draws on each surface Claude Code has (the terminal, the desktop app, VS Code, the phone app) as far as that surface allows, and says so where it can't.
| Plugin | Includes | What it does | Command |
|---|---|---|---|
| chess | mod | Play chess against Claude in a pane, any time, without taking a chat turn. | /chess |
| meowzik | mod | A music player beside the chat: YouTube, SoundCloud, Spotify and Suno, played by you or by Claude. | /meowzik |
chess puts a game against Claude next to the chat. You play White, Claude plays Black, and the game never takes a turn of the conversation, so you can play while Claude works on something else or waits for you.
The rules are complete (castling, en passant, promotion, check, mate, stalemate, the fifty-move rule and bare kings) and checked with perft against the reference counts.
See plugins/chess for the code and its tests.
meowzik is the Meowzik Discord bot as a Claude Code mod: the same player, the same buttons, in a pane next to the chat, and without Discord's limits (no cap on the queue, the history, the favorites or a playlist).
/config.~/.claude/meowzik/lists, so they survive the session.play, control, status, list), so "play some Daft Punk", "skip this" or "what is playing?" just work.~/.claude/meowzik/bin (on macOS mpv and deno come from Homebrew when it is there; on Linux it shows the command for mpv). Nothing else on your system changes./config; like the bot it works on a copy and keeps the session YouTube rotates. Or switch the search source to SoundCloud, which needs no login.Settings (in /config, under meowzik): the mini player, the song in the status line, the search source, the YouTube cookies file or browser, extra yt-dlp arguments, and the mpv and yt-dlp paths if you want your own.
See plugins/meowzik for the code and its tests.
claude-toolkit/
├── .claude-plugin/
│ └── marketplace.json # the catalog: one entry per plugin
├── plugins/
│ ├── chess/ # chess against Claude in a pane (mod)
│ └── meowzik/ # a music player beside the chat (mod)
├── scripts/
│ └── check.sh # validate the marketplace, then validate and test every plugin
└── .github/workflows/ # CI runs scripts/check.sh
Each plugin keeps Claude Code's own layout inside its folder (hooks/ for a mod, .mcp.json for an MCP server, skills/, commands/, agents/), so a plugin that mixes kinds stays in one place.
One plugin, in one step (Claude Code 2.1.275 or later):
/plugin install chess --marketplace jahirxtrap/claude-toolkit
Or add the marketplace once and install the plugins you want:
claude plugin marketplace add jahirxtrap/claude-toolkit
claude plugin install chess@claude-toolkit
From inside Claude Code it is /plugin marketplace add jahirxtrap/claude-toolkit and /plugin install chess@claude-toolkit, then /reload-plugins. To get new versions later, claude plugin marketplace update claude-toolkit.
To try a plugin without installing it:
git clone https://github.com/jahirxtrap/claude-toolkit && cd claude-toolkit
claude --plugin-dir plugins/chess
Built and tested on Claude Code 2.1.306.
bash scripts/check.sh # the marketplace validates, the catalog matches plugins/, every plugin validates and passes its tests
Adding a plugin is one folder under plugins/ plus its entry in marketplace.json; CLAUDE.md has the steps and the rules.
MIT (see LICENSE).
hooks/register.tsx 396 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, RenderInput, RenderSurface } from 'claude-code'
3
4import type { Language, Notice } from '../types'
5import type { BoardProps } from './board'
6import { inCheck, legalMoves, parseMove, replay, squareName, statusOf, uciOf } from './chess'
7import type { Color, GameStatus, PieceType, Position } from './chess'
8import { drawBoard } from './grid'
9import { boardColumnsFor, FILE_LABEL_ROWS, squareRowsFor } from './layout'
10import { DEFAULT_LANGUAGE, isLanguage, isMessageKey, LANGUAGES, languageName, translate } from './locales'
11import type { MessageKey } from './locales'
12import { squareColor } from './palette'
13import { promptFor, readReply, systemPromptFor } from './prompt'
14
15const PANE = 'chess'
16const BOARD_KEY = 'board'
17const GAME_KEY = 'game'
18const LANGUAGE_KEY = 'language'
19const PLAYER: Color = 'w'
20const MODEL = 'sonnet'
21const EFFORT = 'low'
22const ATTEMPTS = 2
23const REPLY_TOKENS = 700
24const REPLY_TIMEOUT_MS = 45_000
25const HISTORY_ROWS = 10
26const SIDE_COLUMNS = 30
27const SIDE_GAP = 3
28const CONTROL_ROWS = 3
29const STATIC_SQUARE_ROWS = 1
30
31const TEXT_PRESENTATION = String.fromCodePoint(0xfe0e)
32
33const GLYPHS: Record<Color, Record<PieceType, string>> = {
34 w: { k: '♔', q: '♕', r: '♖', b: '♗', n: '♘', p: '♙' },
35 b: { k: '♚', q: '♛', r: '♜', b: '♝', n: '♞', p: '♟' },
36}
37
38const DRAW_KEYS: Record<Exclude<GameStatus, 'playing' | 'checkmate'>, MessageKey> = {
39 stalemate: 'stalemate',
40 'fifty-moves': 'fiftyMoves',
41 'insufficient-material': 'insufficientMaterial',
42}
43
44const moves = atom({ plugin: 'chess', key: 'moves' } as const, [] as string[])
45const isThinking = atom({ plugin: 'chess', key: 'isThinking' } as const, false)
46const notice = atom({ plugin: 'chess', key: 'notice' } as const, null as Notice | null)
47const remark = atom({ plugin: 'chess', key: 'remark' } as const, null as string | null)
48const language = atom({ plugin: 'chess', key: 'language' } as const, DEFAULT_LANGUAGE as Language)
49
50let generation = 0
51
52type Game = ReturnType<typeof replay>
53
54const movePairs = (sans: readonly string[]) =>
55 Array.from({ length: Math.ceil(sans.length / 2) }, (_, index) =>
56 `${index + 1}. ${sans[index * 2] ?? ''} ${sans[index * 2 + 1] ?? ''}`.trimEnd(),
57 )
58
59const say = (key: MessageKey, params: Record<string, string> = {}): Notice => ({ key, params })
60
61const noticeText = (current: Language, shown: Notice) =>
62 isMessageKey(shown.key) ? translate(current, shown.key, shown.params) : shown.key
63
64const saveMoves = async ($: EngineInterface, list: string[]) => {
65 await update($, moves, () => list)
66 await $.store.set(GAME_KEY, list)
67}
68
69const askClaude = async ($: EngineInterface, game: Game) => {
70 const legal = legalMoves(game.position)
71 const system = systemPromptFor(languageName(await read($, language)))
72 const prompt = promptFor(game.position, movePairs(game.sans).join(' '), legal)
73
74 for (let attempt = 1; attempt <= ATTEMPTS; attempt += 1) {
75 const answered = await $.model.complete({
76 model: MODEL,
77 effort: EFFORT,
78 system,
79 prompt,
80 maxTokens: REPLY_TOKENS,
81 timeoutMs: REPLY_TIMEOUT_MS,
82 })
83
84 if (!answered.isAnswered) {
85 return { failure: say('noAnswer', { reason: answered.reason }) }
86 }
87
88 const reply = readReply(game.position, answered.text)
89 if (reply.move) {
90 return { move: reply.move, remark: reply.remark }
91 }
92 }
93
94 return { failure: say('noValidMove') }
95}
96
97const claudeMove = async ($: EngineInterface) => {
98 const history = await read($, moves)
99 const game = replay(history)
100
101 if (game.position.turn === PLAYER || statusOf(game.position) !== 'playing') {
102 return
103 }
104
105 generation += 1
106 const request = generation
107 await update($, isThinking, () => true)
108 await update($, notice, () => null)
109
110 try {
111 const answer = await askClaude($, game)
112 if (request !== generation) {
113 return
114 }
115 if ('failure' in answer) {
116 await update($, notice, () => answer.failure)
117 return
118 }
119 await update($, remark, () => answer.remark)
120 await saveMoves($, [...history, uciOf(answer.move)])
121 } finally {
122 if (request === generation) {
123 await update($, isThinking, () => false)
124 }
125 }
126}
127
128const playMove = async ($: EngineInterface, text: string) => {
129 const history = await read($, moves)
130 const game = replay(history)
131 const refuse = (shown: Notice) => update($, notice, () => shown)
132
133 if (statusOf(game.position) !== 'playing') {
134 await refuse(say('gameOver'))
135 return
136 }
137 if (game.position.turn !== PLAYER || (await read($, isThinking))) {
138 await refuse(say('waitForClaude'))
139 return
140 }
141
142 const move = parseMove(game.position, text)
143 if (!move) {
144 await refuse(say('illegalMove', { move: text.trim() }))
145 return
146 }
147
148 await update($, notice, () => null)
149 await update($, remark, () => null)
150 await saveMoves($, [...history, uciOf(move)])
151 void claudeMove($)
152}
153
154const resetThinking = async ($: EngineInterface) => {
155 generation += 1
156 await update($, isThinking, () => false)
157 await update($, notice, () => null)
158 await update($, remark, () => null)
159}
160
161const newGame = async ($: EngineInterface) => {
162 await resetThinking($)
163 await saveMoves($, [])
164}
165
166const undo = async ($: EngineInterface) => {
167 const history = await read($, moves)
168 const drop = history.length % 2 === 1 ? 1 : 2
169
170 await resetThinking($)
171 await saveMoves($, history.slice(0, Math.max(0, history.length - drop)))
172}
173
174const openBoard = async ($: EngineInterface) =>
175 $.ui.open({ id: PANE, title: translate(await read($, language), 'paneTitle') })
176
177const chooseLanguage = async ($: EngineInterface, value: string) => {
178 if (!isLanguage(value)) {
179 return
180 }
181
182 await update($, language, () => value)
183 await $.store.set(LANGUAGE_KEY, value)
184 await openBoard($)
185}
186
187const headlineKeyOf = (position: Position, status: GameStatus, thinking: boolean): MessageKey => {
188 if (status === 'checkmate') {
189 return position.turn === PLAYER ? 'claudeWins' : 'youWin'
190 }
191 if (status !== 'playing') {
192 return DRAW_KEYS[status]
193 }
194 if (thinking) {
195 return 'thinking'
196 }
197 if (position.turn !== PLAYER) {
198 return 'claudesTurn'
199 }
200
201 return inCheck(position) ? 'yourTurnInCheck' : 'yourTurn'
202}
203
204const glyphsOf = (position: Position) =>
205 position.board.map(piece => (piece ? `${GLYPHS[piece.color][piece.type]}${TEXT_PRESENTATION}` : ''))
206
207const lastSquaresOf = (game: Game) => (game.lastMove ? [game.lastMove.from, game.lastMove.to] : [])
208
209const boardPropsOf = (game: Game, isLocked: boolean): BoardProps => {
210 const targets: Record<string, number[]> = {}
211
212 if (!isLocked) {
213 for (const move of legalMoves(game.position)) {
214 const reachable = targets[String(move.from)] ?? []
215 if (!reachable.includes(move.to)) {
216 targets[String(move.from)] = [...reachable, move.to]
217 }
218 }
219 }
220
221 return { squares: glyphsOf(game.position), last: lastSquaresOf(game), targets, isLocked }
222}
223
224const boardClient = ($: EngineInterface, e: RenderInput<'Pane', 'terminal' | 'desktop'>, props: BoardProps) => {
225 const { Client } = $.ui.resolve(e)
226 const isSideBySide = e.props.bodyColumns >= boardColumnsFor(1) + SIDE_GAP + SIDE_COLUMNS
227 const columns = isSideBySide ? e.props.bodyColumns - SIDE_GAP - SIDE_COLUMNS : e.props.bodyColumns
228 const squareRows = squareRowsFor(columns, e.props.scroll.bodyRows - CONTROL_ROWS)
229
230 return (
231 <Client
232 key={BOARD_KEY}
233 module="./board.tsx"
234 props={props}
235 width={boardColumnsFor(squareRows)}
236 height={8 * squareRows + FILE_LABEL_ROWS}
237 />
238 )
239}
240
241const staticBoard = ($: EngineInterface, e: RenderInput<'Pane'>, game: Game) => {
242 const squares = glyphsOf(game.position)
243 const last = lastSquaresOf(game)
244
245 return drawBoard($.ui.resolve(e), {
246 squareRows: STATIC_SQUARE_ROWS,
247 glyphSize: 'body',
248 glyphOf: square => squares[square] ?? '',
249 colorOf: (square, rank, file) => squareColor((rank + file) % 2 === 1, last.includes(square)),
250 })
251}
252
253const moveInput = ($: EngineInterface, e: RenderInput<'Pane', Exclude<RenderSurface, 'mobile'>>, current: Language) => {
254 const { Input } = $.ui.resolve(e)
255
256 return (
257 <Input
258 key="move"
259 label={translate(current, 'moveLabel')}
260 value=""
261 placeholder={translate(current, 'movePlaceholder')}
262 submitLabel={translate(current, 'moveSubmit')}
263 onSubmit={value => void playMove($, value)}
264 />
265 )
266}
267
268const languageSelect = ($: EngineInterface, e: RenderInput<'Pane', Exclude<RenderSurface, 'mobile'>>, current: Language) => {
269 const { Select } = $.ui.resolve(e)
270
271 return (
272 <Select
273 key="language"
274 label={translate(current, 'languageLabel')}
275 options={LANGUAGES.map(option => ({ value: option.value, label: option.label }))}
276 value={current}
277 onSelect={value => void chooseLanguage($, value)}
278 />
279 )
280}
281
282const languageToggle = ($: EngineInterface, e: RenderInput<'Pane', 'mobile'>, current: Language) => {
283 const { Button } = $.ui.resolve(e)
284 const next = LANGUAGES.find(option => option.value !== current) ?? LANGUAGES[0]
285
286 return next && <Button key="language" dimColor label={next.label} onPress={() => void chooseLanguage($, next.value)} />
287}
288
289const isBoardMove = (data: unknown): data is { move: [number, number] } => {
290 const move = (data as { move?: unknown } | null)?.move
291
292 return Array.isArray(move) && move.length === 2 && move.every(square => Number.isInteger(square) && square >= 0 && square < 64)
293}
294
295export const register: Register = on => {
296 on('session.start', async ($, e, next) => {
297 await $.command.register({
298 name: 'chess',
299 description: 'Play chess against Claude in a pane, any time',
300 })
301 const [savedGame, savedLanguage] = await Promise.all([$.store.get(GAME_KEY), $.store.get(LANGUAGE_KEY)])
302
303 if (Array.isArray(savedGame) && savedGame.every(item => typeof item === 'string')) {
304 await update($, moves, () => savedGame as string[])
305 }
306 if (isLanguage(savedLanguage)) {
307 await update($, language, () => savedLanguage)
308 }
309
310 return next(e)
311 })
312
313 on('command.run', { command: 'chess' }, async $ => {
314 const opened = await openBoard($)
315 const current = await read($, language)
316
317 return {
318 text: opened.isPlaced
319 ? translate(current, 'boardOpened')
320 : translate(current, 'boardWaiting', { reason: opened.reason }),
321 }
322 })
323
324 on('ui.message', async ($, e) => {
325 if (e.element === BOARD_KEY && isBoardMove(e.data)) {
326 const [from, to] = e.data.move
327 await playMove($, `${squareName(from)}${squareName(to)}`)
328 }
329
330 return {}
331 })
332
333 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
334 const { Box, Button, Text } = $.ui.resolve(e)
335 const [history, thinking, shown, said, current] = await Promise.all([
336 read($, moves),
337 read($, isThinking),
338 read($, notice),
339 read($, remark),
340 read($, language),
341 ])
342 const game = replay(history)
343 const status = statusOf(game.position)
344 const isPlayersTurn = game.position.turn === PLAYER && status === 'playing' && !thinking
345 const isClaudesTurn = game.position.turn !== PLAYER && status === 'playing' && !thinking
346
347 return (
348 <Box flexDirection="column" gap={1}>
349 <Box flexWrap="wrap" columnGap={SIDE_GAP} rowGap={1}>
350 {e.surface === 'terminal' || e.surface === 'desktop'
351 ? boardClient($, e, boardPropsOf(game, !isPlayersTurn))
352 : staticBoard($, e, game)}
353
354 <Box flexDirection="column" gap={1} width={SIDE_COLUMNS}>
355 <Text weight="bold">{translate(current, headlineKeyOf(game.position, status, thinking))}</Text>
356 {said !== null && !thinking && <Text italic>{translate(current, 'claudeSays', { remark: said })}</Text>}
357 {(e.surface === 'terminal' || e.surface === 'desktop') && (
358 <Text dimColor>{translate(current, 'clickHint')}</Text>
359 )}
360 {game.sans.length === 0 ? (
361 <Text dimColor>{translate(current, 'youOpen')}</Text>
362 ) : (
363 <Box flexDirection="column">
364 {movePairs(game.sans)
365 .slice(-HISTORY_ROWS)
366 .map(row => (
367 <Text mono>{row}</Text>
368 ))}
369 </Box>
370 )}
371 {shown !== null && <Text color="error">{noticeText(current, shown)}</Text>}
372 </Box>
373 </Box>
374
375 {e.surface === 'vscode' && moveInput($, e, current)}
376 {e.surface === 'mobile' && <Text dimColor>{translate(current, 'phoneHint')}</Text>}
377
378 <Box flexWrap="wrap" columnGap={1} rowGap={1} alignItems="center">
379 <Button key="new" hotkey="n" dimColor label={translate(current, 'newGame')} onPress={() => void newGame($)} />
380 <Button key="undo" hotkey="u" dimColor label={translate(current, 'undo')} onPress={() => void undo($)} />
381 {isClaudesTurn && (
382 <Button
383 key="retry"
384 hotkey="r"
385 variant="primary"
386 label={translate(current, 'askClaude')}
387 onPress={() => void claudeMove($)}
388 />
389 )}
390 {e.surface === 'mobile' ? languageToggle($, e, current) : languageSelect($, e, current)}
391 </Box>
392 </Box>
393 )
394 })
395}
396hooks/board.tsx 118 lines1import type { ClientModule, ClientSurface } from 'claude-code'
2
3import { drawBoard } from './grid'
4import type { GlyphSize } from './grid'
5import { RANK_LABEL_COLUMNS, squareColumnsFor, squareRowsFor } from './layout'
6import { BOARD_COLORS, squareColor } from './palette'
7
8export type BoardProps = {
9 squares: string[]
10 last: number[]
11 targets: Record<string, number[]>
12 isLocked: boolean
13}
14
15type BoardState = {
16 cursor: number
17 selected: number | null
18 usesKeys: boolean
19 live: { props: BoardProps; squareRows: number }
20}
21
22const START_CURSOR = 12
23const TARGET_MARK = '•'
24const GLYPH_SIZES: readonly GlyphSize[] = ['body', 'body', 'heading', 'title']
25const STEPS: Partial<Record<string, readonly [number, number]>> = {
26 up: [0, 1],
27 down: [0, -1],
28 left: [-1, 0],
29 right: [1, 0],
30}
31const CHOOSE_KEYS = ['return', ' ']
32
33const squareAt = (x: number, y: number, squareRows: number) => {
34 const file = Math.floor((x - RANK_LABEL_COLUMNS) / squareColumnsFor(squareRows))
35 const rank = 7 - Math.floor(y / squareRows)
36
37 return x < RANK_LABEL_COLUMNS || file > 7 || rank < 0 || rank > 7 ? null : rank * 8 + file
38}
39
40const stepped = (square: number, [files, ranks]: readonly [number, number]) => {
41 const file = Math.min(7, Math.max(0, (square % 8) + files))
42 const rank = Math.min(7, Math.max(0, Math.floor(square / 8) + ranks))
43
44 return rank * 8 + file
45}
46
47const choose = (surface: ClientSurface<BoardState>, square: number, usesKeys: boolean) => {
48 const now = surface.state
49 if (!now) {
50 return
51 }
52
53 const { targets, isLocked } = now.live.props
54 const reachable = now.selected === null ? [] : (targets[String(now.selected)] ?? [])
55
56 if (!isLocked && now.selected !== null && reachable.includes(square)) {
57 surface.post({ move: [now.selected, square] })
58 surface.setState({ ...now, cursor: square, selected: null, usesKeys })
59 return
60 }
61
62 const canSelect = !isLocked && targets[String(square)] !== undefined && now.selected !== square
63 surface.setState({ ...now, cursor: square, selected: canSelect ? square : null, usesKeys })
64}
65
66const ChessBoard: ClientModule<BoardProps, BoardState> = (props, surface) => {
67 const squareRows = squareRowsFor(surface.columns, surface.rows)
68 const glyphSize = GLYPH_SIZES[Math.min(squareRows, GLYPH_SIZES.length - 1)] ?? 'body'
69
70 if (surface.state === undefined) {
71 surface.onPointer(event => {
72 const live = surface.state?.live.squareRows ?? 1
73 const square = event.type === 'down' && event.button === 'left' ? squareAt(event.x, event.y, live) : null
74 if (square !== null) {
75 choose(surface, square, false)
76 }
77 })
78 surface.onKey(event => {
79 const now = surface.state
80 const step = STEPS[event.key]
81 if (!now) {
82 return
83 }
84 if (step) {
85 surface.setState({ ...now, cursor: stepped(now.cursor, step), usesKeys: true })
86 } else if (CHOOSE_KEYS.includes(event.key)) {
87 choose(surface, now.cursor, true)
88 }
89 })
90 surface.setState({ cursor: START_CURSOR, selected: null, usesKeys: false, live: { props, squareRows } })
91 }
92
93 const state = surface.state ?? { cursor: START_CURSOR, selected: null, usesKeys: false, live: { props, squareRows } }
94 state.live.props = props
95 state.live.squareRows = squareRows
96
97 const selected = props.isLocked ? null : state.selected
98 const reachable = selected === null ? [] : (props.targets[String(selected)] ?? [])
99
100 const colorOf = (square: number, rank: number, file: number) =>
101 state.usesKeys && state.cursor === square
102 ? BOARD_COLORS.cursor
103 : square === selected
104 ? BOARD_COLORS.selected
105 : reachable.includes(square)
106 ? BOARD_COLORS.target
107 : squareColor((rank + file) % 2 === 1, props.last.includes(square))
108
109 return drawBoard(surface.elements, {
110 squareRows,
111 glyphSize,
112 glyphOf: square => props.squares[square] || (reachable.includes(square) ? TARGET_MARK : ''),
113 colorOf,
114 })
115}
116
117export default ChessBoard
118hooks/chess.ts 435 lines1export type Color = 'w' | 'b'
2export type PieceType = 'p' | 'n' | 'b' | 'r' | 'q' | 'k'
3export type Piece = { color: Color; type: PieceType }
4export type Castling = { wk: boolean; wq: boolean; bk: boolean; bq: boolean }
5
6export type Position = {
7 board: readonly (Piece | null)[]
8 turn: Color
9 castling: Castling
10 enPassant: number | null
11 halfmove: number
12 fullmove: number
13}
14
15export type Move = {
16 from: number
17 to: number
18 piece: PieceType
19 captured: PieceType | null
20 promotion: PieceType | null
21 castle: 'k' | 'q' | null
22 isEnPassant: boolean
23}
24
25export type GameStatus = 'playing' | 'checkmate' | 'stalemate' | 'fifty-moves' | 'insufficient-material'
26
27export const START_FEN = 'rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1'
28
29const FILES = 'abcdefgh'
30const PROMOTIONS: readonly PieceType[] = ['q', 'r', 'b', 'n']
31const KNIGHT_STEPS = [[1, 2], [2, 1], [2, -1], [1, -2], [-1, -2], [-2, -1], [-2, 1], [-1, 2]] as const
32const KING_STEPS = [[1, 0], [1, 1], [0, 1], [-1, 1], [-1, 0], [-1, -1], [0, -1], [1, -1]] as const
33const DIAGONALS = [[1, 1], [1, -1], [-1, 1], [-1, -1]] as const
34const LINES = [[1, 0], [-1, 0], [0, 1], [0, -1]] as const
35const SLIDES: Partial<Record<PieceType, readonly (readonly [number, number])[]>> = {
36 b: DIAGONALS,
37 r: LINES,
38 q: [...DIAGONALS, ...LINES],
39}
40const CORNER_RIGHTS: Partial<Record<number, keyof Castling>> = { 0: 'wq', 7: 'wk', 56: 'bq', 63: 'bk' }
41
42export const fileOf = (square: number) => square % 8
43export const rankOf = (square: number) => Math.floor(square / 8)
44export const opponent = (color: Color): Color => (color === 'w' ? 'b' : 'w')
45export const squareName = (square: number) => `${FILES.charAt(fileOf(square))}${rankOf(square) + 1}`
46export const uciOf = (move: Move) => `${squareName(move.from)}${squareName(move.to)}${move.promotion ?? ''}`
47
48const forward = (color: Color) => (color === 'w' ? 1 : -1)
49
50const step = (square: number, files: number, ranks: number) => {
51 const file = fileOf(square) + files
52 const rank = rankOf(square) + ranks
53
54 return file < 0 || file > 7 || rank < 0 || rank > 7 ? null : rank * 8 + file
55}
56
57export const squareFrom = (name: string) => {
58 const file = FILES.indexOf(name.charAt(0))
59 const rank = Number(name.charAt(1)) - 1
60
61 return name.length === 2 && file >= 0 && rank >= 0 && rank <= 7 ? rank * 8 + file : null
62}
63
64export const parseFen = (fen: string): Position => {
65 const [placement = '', turn = 'w', castling = '-', enPassant = '-', halfmove = '0', fullmove = '1'] = fen.trim().split(/\s+/)
66 const board: (Piece | null)[] = Array.from({ length: 64 }, () => null)
67
68 placement.split('/').forEach((row, index) => {
69 let file = 0
70 for (const char of row) {
71 if (/\d/.test(char)) {
72 file += Number(char)
73 continue
74 }
75 board[(7 - index) * 8 + file] = {
76 color: char === char.toUpperCase() ? 'w' : 'b',
77 type: char.toLowerCase() as PieceType,
78 }
79 file += 1
80 }
81 })
82
83 return {
84 board,
85 turn: turn === 'b' ? 'b' : 'w',
86 castling: {
87 wk: castling.includes('K'),
88 wq: castling.includes('Q'),
89 bk: castling.includes('k'),
90 bq: castling.includes('q'),
91 },
92 enPassant: enPassant === '-' ? null : squareFrom(enPassant),
93 halfmove: Number(halfmove),
94 fullmove: Number(fullmove),
95 }
96}
97
98export const toFen = (position: Position) => {
99 const rows = Array.from({ length: 8 }, (_, index) => {
100 const rank = 7 - index
101 let row = ''
102 let empty = 0
103
104 for (let file = 0; file < 8; file += 1) {
105 const piece = position.board[rank * 8 + file]
106 if (!piece) {
107 empty += 1
108 continue
109 }
110 row += `${empty > 0 ? empty : ''}${piece.color === 'w' ? piece.type.toUpperCase() : piece.type}`
111 empty = 0
112 }
113
114 return `${row}${empty > 0 ? empty : ''}`
115 })
116 const { wk, wq, bk, bq } = position.castling
117 const rights = `${wk ? 'K' : ''}${wq ? 'Q' : ''}${bk ? 'k' : ''}${bq ? 'q' : ''}` || '-'
118 const enPassant = position.enPassant === null ? '-' : squareName(position.enPassant)
119
120 return [rows.join('/'), position.turn, rights, enPassant, position.halfmove, position.fullmove].join(' ')
121}
122
123export const isAttacked = (board: Position['board'], square: number, by: Color) => {
124 const holds = (target: number | null, types: string) => {
125 const piece = target === null ? null : board[target]
126
127 return !!piece && piece.color === by && types.includes(piece.type)
128 }
129 const ray = (directions: readonly (readonly [number, number])[], types: string) =>
130 directions.some(([files, ranks]) => {
131 let target = step(square, files, ranks)
132 while (target !== null) {
133 const piece = board[target]
134 if (piece) {
135 return piece.color === by && types.includes(piece.type)
136 }
137 target = step(target, files, ranks)
138 }
139 return false
140 })
141 const pawnRank = -forward(by)
142
143 return (
144 holds(step(square, -1, pawnRank), 'p') ||
145 holds(step(square, 1, pawnRank), 'p') ||
146 KNIGHT_STEPS.some(([files, ranks]) => holds(step(square, files, ranks), 'n')) ||
147 KING_STEPS.some(([files, ranks]) => holds(step(square, files, ranks), 'k')) ||
148 ray(DIAGONALS, 'bq') ||
149 ray(LINES, 'rq')
150 )
151}
152
153export const inCheck = (position: Position, color: Color = position.turn) => {
154 const king = position.board.findIndex(piece => piece?.color === color && piece.type === 'k')
155
156 return king >= 0 && isAttacked(position.board, king, opponent(color))
157}
158
159const moveOf = (position: Position, from: number, to: number, extra: Partial<Move> = {}): Move => ({
160 from,
161 to,
162 piece: position.board[from]?.type ?? 'p',
163 captured: position.board[to]?.type ?? null,
164 promotion: null,
165 castle: null,
166 isEnPassant: false,
167 ...extra,
168})
169
170const pawnMoves = (position: Position, from: number): Move[] => {
171 const color = position.turn
172 const direction = forward(color)
173 const lastRank = color === 'w' ? 7 : 0
174 const moves: Move[] = []
175 const add = (to: number, extra: Partial<Move> = {}) => {
176 if (rankOf(to) === lastRank) {
177 for (const promotion of PROMOTIONS) {
178 moves.push(moveOf(position, from, to, { ...extra, promotion }))
179 }
180 } else {
181 moves.push(moveOf(position, from, to, extra))
182 }
183 }
184 const one = step(from, 0, direction)
185
186 if (one !== null && !position.board[one]) {
187 add(one)
188 const two = step(from, 0, 2 * direction)
189 if (rankOf(from) === (color === 'w' ? 1 : 6) && two !== null && !position.board[two]) {
190 add(two)
191 }
192 }
193
194 for (const side of [-1, 1]) {
195 const to = step(from, side, direction)
196 if (to === null) {
197 continue
198 }
199 const target = position.board[to]
200 if (target && target.color !== color) {
201 add(to)
202 } else if (!target && to === position.enPassant) {
203 add(to, { captured: 'p', isEnPassant: true })
204 }
205 }
206
207 return moves
208}
209
210const pieceMoves = (position: Position, from: number): Move[] => {
211 const piece = position.board[from]
212 if (!piece) {
213 return []
214 }
215
216 if (piece.type === 'n' || piece.type === 'k') {
217 return (piece.type === 'n' ? KNIGHT_STEPS : KING_STEPS)
218 .map(([files, ranks]) => step(from, files, ranks))
219 .filter((to): to is number => to !== null && position.board[to]?.color !== piece.color)
220 .map(to => moveOf(position, from, to))
221 }
222
223 const moves: Move[] = []
224 for (const [files, ranks] of SLIDES[piece.type] ?? []) {
225 let to = step(from, files, ranks)
226 while (to !== null) {
227 const target = position.board[to]
228 if (target?.color === piece.color) {
229 break
230 }
231 moves.push(moveOf(position, from, to))
232 if (target) {
233 break
234 }
235 to = step(to, files, ranks)
236 }
237 }
238
239 return moves
240}
241
242const castlingMoves = (position: Position): Move[] => {
243 const color = position.turn
244 const home = color === 'w' ? 0 : 56
245 const king = home + 4
246 const kingPiece = position.board[king]
247
248 if (kingPiece?.type !== 'k' || kingPiece.color !== color || inCheck(position, color)) {
249 return []
250 }
251
252 const enemy = opponent(color)
253 const sides = [
254 { castle: 'k', right: color === 'w' ? 'wk' : 'bk', rook: 7, empty: [5, 6], safe: [5, 6], target: 6 },
255 { castle: 'q', right: color === 'w' ? 'wq' : 'bq', rook: 0, empty: [1, 2, 3], safe: [3, 2], target: 2 },
256 ] as const
257
258 return sides
259 .filter(side => {
260 const rook = position.board[home + side.rook]
261
262 return (
263 position.castling[side.right] &&
264 rook?.type === 'r' &&
265 rook.color === color &&
266 side.empty.every(offset => !position.board[home + offset]) &&
267 side.safe.every(offset => !isAttacked(position.board, home + offset, enemy))
268 )
269 })
270 .map(side => moveOf(position, king, home + side.target, { castle: side.castle }))
271}
272
273const pseudoMoves = (position: Position): Move[] => [
274 ...position.board.flatMap((piece, from) => {
275 if (!piece || piece.color !== position.turn) {
276 return []
277 }
278 return piece.type === 'p' ? pawnMoves(position, from) : pieceMoves(position, from)
279 }),
280 ...castlingMoves(position),
281]
282
283export const applyMove = (position: Position, move: Move): Position => {
284 const board = [...position.board]
285 const color = position.turn
286 const piece = board[move.from] ?? { color, type: move.piece }
287
288 board[move.to] = move.promotion ? { color, type: move.promotion } : piece
289 board[move.from] = null
290
291 if (move.isEnPassant) {
292 board[move.to - 8 * forward(color)] = null
293 }
294
295 if (move.castle) {
296 const home = color === 'w' ? 0 : 56
297 const [rookFrom, rookTo] = move.castle === 'k' ? [home + 7, home + 5] : [home, home + 3]
298 board[rookTo] = board[rookFrom] ?? null
299 board[rookFrom] = null
300 }
301
302 const castling = { ...position.castling }
303 if (piece.type === 'k') {
304 castling[color === 'w' ? 'wk' : 'bk'] = false
305 castling[color === 'w' ? 'wq' : 'bq'] = false
306 }
307 for (const square of [move.from, move.to]) {
308 const right = CORNER_RIGHTS[square]
309 if (right) {
310 castling[right] = false
311 }
312 }
313
314 return {
315 board,
316 turn: opponent(color),
317 castling,
318 enPassant: piece.type === 'p' && Math.abs(move.to - move.from) === 16 ? (move.from + move.to) / 2 : null,
319 halfmove: piece.type === 'p' || move.captured ? 0 : position.halfmove + 1,
320 fullmove: position.fullmove + (color === 'b' ? 1 : 0),
321 }
322}
323
324export const legalMoves = (position: Position): Move[] =>
325 pseudoMoves(position).filter(move => !inCheck(applyMove(position, move), position.turn))
326
327export const statusOf = (position: Position): GameStatus => {
328 if (legalMoves(position).length === 0) {
329 return inCheck(position) ? 'checkmate' : 'stalemate'
330 }
331 if (position.halfmove >= 100) {
332 return 'fifty-moves'
333 }
334 const rest = position.board.filter((piece): piece is Piece => piece !== null && piece.type !== 'k')
335 const lone = rest[0]
336 if (rest.length === 0 || (rest.length === 1 && lone !== undefined && (lone.type === 'n' || lone.type === 'b'))) {
337 return 'insufficient-material'
338 }
339
340 return 'playing'
341}
342
343const sanBody = (position: Position, move: Move) => {
344 if (move.castle) {
345 return move.castle === 'k' ? 'O-O' : 'O-O-O'
346 }
347
348 const capture = move.captured === null ? '' : 'x'
349
350 if (move.piece === 'p') {
351 const origin = capture ? FILES.charAt(fileOf(move.from)) : ''
352 const promotion = move.promotion ? `=${move.promotion.toUpperCase()}` : ''
353
354 return `${origin}${capture}${squareName(move.to)}${promotion}`
355 }
356
357 const rivals = legalMoves(position).filter(
358 other => other.piece === move.piece && other.to === move.to && other.from !== move.from,
359 )
360 const sharesFile = rivals.some(other => fileOf(other.from) === fileOf(move.from))
361 const sharesRank = rivals.some(other => rankOf(other.from) === rankOf(move.from))
362 const origin =
363 rivals.length === 0
364 ? ''
365 : !sharesFile
366 ? FILES.charAt(fileOf(move.from))
367 : !sharesRank
368 ? String(rankOf(move.from) + 1)
369 : squareName(move.from)
370
371 return `${move.piece.toUpperCase()}${origin}${capture}${squareName(move.to)}`
372}
373
374export const toSan = (position: Position, move: Move) => {
375 const after = applyMove(position, move)
376 const suffix = inCheck(after) ? (legalMoves(after).length === 0 ? '#' : '+') : ''
377
378 return `${sanBody(position, move)}${suffix}`
379}
380
381const normalize = (text: string) =>
382 text
383 .trim()
384 .replace(/[+#!?\s]/g, '')
385 .replace(/0/g, 'O')
386 .replace(/^o-o-o$/i, 'O-O-O')
387 .replace(/^o-o$/i, 'O-O')
388
389const loose = (san: string) => san.replace(/[x=]/g, '')
390
391export const parseMove = (position: Position, text: string): Move | null => {
392 const legal = legalMoves(position)
393 const plain = normalize(text)
394 const uci = /^([a-h][1-8])([a-h][1-8])([qrbn])?$/i.exec(plain)
395
396 if (uci) {
397 const [, from = '', to = '', promotion] = uci
398 const matches = legal.filter(
399 move => squareName(move.from) === from.toLowerCase() && squareName(move.to) === to.toLowerCase(),
400 )
401 const wanted = promotion?.toLowerCase() ?? (matches.some(move => move.promotion) ? 'q' : null)
402
403 return matches.find(move => move.promotion === wanted) ?? null
404 }
405
406 const candidates = [plain, /^[nrqk]/.test(plain) ? `${plain.charAt(0).toUpperCase()}${plain.slice(1)}` : plain]
407
408 return (
409 legal.find(move => {
410 const san = loose(sanBody(position, move))
411 const unnamedQueen = move.promotion === 'q' ? san.slice(0, -1) : null
412
413 return candidates.some(candidate => loose(candidate) === san || loose(candidate) === unnamedQueen)
414 }) ?? null
415 )
416}
417
418export const replay = (history: readonly string[]) => {
419 let position = parseFen(START_FEN)
420 let lastMove: Move | null = null
421 const sans: string[] = []
422
423 for (const text of history) {
424 const move = parseMove(position, text)
425 if (!move) {
426 break
427 }
428 sans.push(toSan(position, move))
429 position = applyMove(position, move)
430 lastMove = move
431 }
432
433 return { position, sans, lastMove }
434}
435hooks/grid.tsx 55 lines1import type { ClientElements } from 'claude-code'
2
3import { FILE_INDEXES, FILE_LABEL_ROWS, FILE_LETTERS, RANK_LABEL_COLUMNS, RANKS, squareColumnsFor } from './layout'
4import { BOARD_COLORS } from './palette'
5
6export type GlyphSize = 'body' | 'heading' | 'title'
7
8export type BoardGrid = {
9 squareRows: number
10 glyphSize: GlyphSize
11 glyphOf: (square: number) => string
12 colorOf: (square: number, rank: number, file: number) => string
13}
14
15export const drawBoard = ({ Box, Text }: Pick<ClientElements, 'Box' | 'Text'>, grid: BoardGrid) => {
16 const squareColumns = squareColumnsFor(grid.squareRows)
17
18 return (
19 <Box flexDirection="column">
20 {RANKS.map(rank => (
21 <Box flexDirection="row">
22 <Box width={RANK_LABEL_COLUMNS} height={grid.squareRows} alignItems="center">
23 <Text dimColor>{String(rank + 1)}</Text>
24 </Box>
25 {FILE_INDEXES.map(file => {
26 const square = rank * 8 + file
27
28 return (
29 <Box
30 width={squareColumns}
31 height={grid.squareRows}
32 justifyContent="center"
33 alignItems="center"
34 backgroundColor={grid.colorOf(square, rank, file)}
35 >
36 <Text size={grid.glyphSize} color={BOARD_COLORS.piece}>
37 {grid.glyphOf(square) || ' '}
38 </Text>
39 </Box>
40 )
41 })}
42 </Box>
43 ))}
44 <Box flexDirection="row">
45 <Box width={RANK_LABEL_COLUMNS} height={FILE_LABEL_ROWS} />
46 {FILE_LETTERS.map(letter => (
47 <Box width={squareColumns} height={FILE_LABEL_ROWS} justifyContent="center">
48 <Text dimColor>{letter}</Text>
49 </Box>
50 ))}
51 </Box>
52 </Box>
53 )
54}
55hooks/layout.ts 18 lines1export const RANK_LABEL_COLUMNS = 2
2export const FILE_LABEL_ROWS = 1
3export const MAX_SQUARE_ROWS = 5
4export const RANKS = [7, 6, 5, 4, 3, 2, 1, 0]
5export const FILE_INDEXES = [0, 1, 2, 3, 4, 5, 6, 7]
6export const FILE_LETTERS = ['a', 'b', 'c', 'd', 'e', 'f', 'g', 'h']
7
8export const squareColumnsFor = (squareRows: number) => 2 * squareRows + 1
9
10export const boardColumnsFor = (squareRows: number) => RANK_LABEL_COLUMNS + 8 * squareColumnsFor(squareRows)
11
12export const squareRowsFor = (columns: number, rows: number) => {
13 const byRows = Math.floor((rows - FILE_LABEL_ROWS) / 8)
14 const byColumns = Math.floor(((columns - RANK_LABEL_COLUMNS) / 8 - 1) / 2)
15
16 return Math.max(1, Math.min(byRows, byColumns, MAX_SQUARE_ROWS))
17}
18hooks/locales.ts 85 lines1import type { Language } from '../types'
2
3export const LANGUAGES: readonly { value: Language; label: string; name: string }[] = [
4 { value: 'en', label: 'English', name: 'English' },
5 { value: 'es', label: 'Español', name: 'Spanish' },
6]
7
8export const DEFAULT_LANGUAGE: Language = 'en'
9
10const ENGLISH = {
11 paneTitle: 'Chess',
12 boardOpened: 'Board open.',
13 boardWaiting: 'The board is waiting: {reason}',
14 yourTurn: 'Your move: you play White.',
15 yourTurnInCheck: 'Check! Your move: you play White.',
16 claudesTurn: "Claude's move.",
17 thinking: 'Claude is thinking…',
18 youWin: 'Checkmate! You win.',
19 claudeWins: 'Checkmate. Claude wins.',
20 stalemate: 'Draw by stalemate.',
21 fiftyMoves: 'Draw by the fifty-move rule.',
22 insufficientMaterial: 'Draw: not enough material to mate.',
23 youOpen: 'You open.',
24 clickHint: 'Click a piece, then its square. Keyboard: arrows and Enter.',
25 phoneHint: 'Open the board on a computer to play.',
26 moveLabel: 'Your move',
27 movePlaceholder: 'e4, Nf3, O-O or e2e4',
28 moveSubmit: 'play',
29 newGame: 'New game',
30 undo: 'Undo',
31 askClaude: 'Ask Claude to move',
32 languageLabel: 'Language',
33 gameOver: 'The game is over: start a new one.',
34 waitForClaude: 'Wait for Claude to move.',
35 illegalMove: '“{move}” is not a legal move.',
36 noAnswer: 'Claude did not answer ({reason}).',
37 noValidMove: 'Claude did not give a valid move.',
38 claudeSays: 'Claude: {remark}',
39}
40
41export type MessageKey = keyof typeof ENGLISH
42
43const SPANISH: Record<MessageKey, string> = {
44 paneTitle: 'Ajedrez',
45 boardOpened: 'Tablero abierto.',
46 boardWaiting: 'El tablero quedó a la espera: {reason}',
47 yourTurn: 'Te toca: juegas con blancas.',
48 yourTurnInCheck: '¡Jaque! Te toca: juegas con blancas.',
49 claudesTurn: 'Le toca a Claude.',
50 thinking: 'Claude está pensando…',
51 youWin: '¡Jaque mate! Ganas tú.',
52 claudeWins: 'Jaque mate. Gana Claude.',
53 stalemate: 'Tablas por ahogado.',
54 fiftyMoves: 'Tablas por la regla de los 50 movimientos.',
55 insufficientMaterial: 'Tablas: no queda material para dar mate.',
56 youOpen: 'Abres tú.',
57 clickHint: 'Pulsa una pieza y luego su destino. Con el teclado: flechas y Enter.',
58 phoneHint: 'Abre el tablero en un ordenador para jugar.',
59 moveLabel: 'Tu jugada',
60 movePlaceholder: 'e4, Nf3, O-O o e2e4',
61 moveSubmit: 'jugar',
62 newGame: 'Nueva partida',
63 undo: 'Deshacer',
64 askClaude: 'Pedir jugada a Claude',
65 languageLabel: 'Idioma',
66 gameOver: 'La partida terminó: empieza una nueva.',
67 waitForClaude: 'Espera a que juegue Claude.',
68 illegalMove: '«{move}» no es una jugada legal.',
69 noAnswer: 'Claude no respondió ({reason}).',
70 noValidMove: 'Claude no devolvió una jugada válida.',
71 claudeSays: 'Claude: {remark}',
72}
73
74const MESSAGES: Record<Language, Record<MessageKey, string>> = { en: ENGLISH, es: SPANISH }
75
76export const isLanguage = (value: unknown): value is Language => LANGUAGES.some(language => language.value === value)
77
78export const isMessageKey = (value: string): value is MessageKey => Object.hasOwn(ENGLISH, value)
79
80export const languageName = (language: Language) =>
81 LANGUAGES.find(candidate => candidate.value === language)?.name ?? 'English'
82
83export const translate = (language: Language, key: MessageKey, params: Readonly<Record<string, string>> = {}) =>
84 Object.entries(params).reduce((text, [name, value]) => text.replaceAll(`{${name}}`, value), MESSAGES[language][key])
85hooks/palette.ts 20 lines1export const BOARD_COLORS = {
2 light: '#f0d9b5',
3 dark: '#b58863',
4 lightLast: '#cdd26a',
5 darkLast: '#aaa23a',
6 selected: '#f7ec74',
7 target: '#a9c26b',
8 cursor: '#86a666',
9 piece: '#111111',
10} as const
11
12export const squareColor = (isLight: boolean, isLast: boolean) =>
13 isLast
14 ? isLight
15 ? BOARD_COLORS.lightLast
16 : BOARD_COLORS.darkLast
17 : isLight
18 ? BOARD_COLORS.light
19 : BOARD_COLORS.dark
20hooks/prompt.ts 72 lines1import { parseMove, squareName, toFen, uciOf } from './chess'
2import type { Move, Piece, Position } from './chess'
3import { FILE_INDEXES, FILE_LETTERS, RANKS } from './layout'
4
5const MAX_THINKING_WORDS = 120
6const MAX_REMARK_LENGTH = 200
7const EMPTY_SQUARE = '.'
8const PIECE_ORDER = 'kqrbnp'
9
10const MOVE_LINE = /^\s*MOVE:\s*(\S+)/im
11const SAY_LINE = /^\s*SAY:\s*(.+)$/im
12const UCI_TOKEN = /[a-h][1-8][a-h][1-8][qrbn]?/gi
13
14export const systemPromptFor = (languageName: string) =>
15 [
16 'You are Claude, playing a friendly game of chess as Black against the user.',
17 'Think briefly before you move: read the board, check what White threatens (checks, captures,',
18 'mating ideas such as an open back rank) and whether your king has an escape square, then',
19 `compare two or three candidate moves. Keep your thinking under ${MAX_THINKING_WORDS} words.`,
20 'Then end with exactly these two lines and nothing after them:',
21 'MOVE: <your move in UCI notation, taken from the list of legal moves>',
22 `SAY: <one short, friendly sentence to the user about your move, in ${languageName}>`,
23 ].join('\n')
24
25const letterOf = (piece: Piece) => (piece.color === 'w' ? piece.type.toUpperCase() : piece.type)
26
27export const diagramOf = (position: Position) =>
28 [
29 ...RANKS.map(
30 rank =>
31 `${rank + 1} ${FILE_INDEXES.map(file => {
32 const piece = position.board[rank * 8 + file]
33 return piece ? letterOf(piece) : EMPTY_SQUARE
34 }).join(' ')}`,
35 ),
36 ` ${FILE_LETTERS.join(' ')}`,
37 ].join('\n')
38
39const piecesOf = (position: Position, color: Piece['color']) =>
40 position.board
41 .flatMap((piece, square) =>
42 piece?.color === color ? [{ type: piece.type, name: `${piece.type.toUpperCase()}${squareName(square)}` }] : [],
43 )
44 .sort((first, second) => PIECE_ORDER.indexOf(first.type) - PIECE_ORDER.indexOf(second.type))
45 .map(piece => piece.name)
46 .join(' ')
47
48export const promptFor = (position: Position, history: string, legal: readonly Move[]) =>
49 [
50 'Board (White at the bottom; uppercase is White, lowercase is Black, a dot is empty):',
51 diagramOf(position),
52 '',
53 `Black pieces (yours): ${piecesOf(position, 'b')}`,
54 `White pieces: ${piecesOf(position, 'w')}`,
55 `FEN: ${toFen(position)}`,
56 `Moves so far: ${history === '' ? 'none' : history}`,
57 `Legal moves for Black (UCI): ${legal.map(uciOf).join(' ')}`,
58 ].join('\n')
59
60export const readReply = (position: Position, reply: string) => {
61 const named = MOVE_LINE.exec(reply)?.[1]
62 const fromLine = named === undefined ? null : parseMove(position, named)
63 const tokens = reply.match(UCI_TOKEN) ?? []
64 const lastLegal = tokens
65 .map(token => parseMove(position, token))
66 .filter(move => move !== null)
67 .at(-1)
68 const remark = SAY_LINE.exec(reply)?.[1]?.trim().slice(0, MAX_REMARK_LENGTH) ?? null
69
70 return { move: fromLine ?? lastLegal ?? null, remark: remark === '' ? null : remark }
71}
72types/index.d.ts 16 lines1export type Language = 'en' | 'es'
2
3export type Notice = { key: string; params: Record<string, string> }
4
5declare module 'claude-code' {
6 interface PluginState {
7 chess: {
8 moves: string[]
9 isThinking: boolean
10 notice: Notice | null
11 remark: string | null
12 language: Language
13 }
14 }
15}
16