/stock: watch tickers and get a chime when one reaches the low or high you set. Works any time, in any session.

Watch stock and crypto prices from inside Claude Code, and get a chime and a toast when one reaches a low or a high you set. Or set no limits at all and just keep an eye on the price. It's a mod: a plugin whose code runs inside Claude Code.
It shows your tickers on one small line under the prompt:

stock-watch AAPL 342.46 ▲ · NVDA 227.35 ▼ · BTC-USD 83,094 ▲ +2
+2: two more tickers are watched but not shown. The /stock pane shows all of them.HIGH / LOW: appears after a ticker once it has hit one of your limits, for example AAPL 351.20 ▲ HIGH.The pictures in this README are illustrations drawn to match what the mod prints. They are not screenshots of a live session, and the prices in them are examples.
/stock add AAPL just watch the price, no alerts
/stock add AAPL low 335 high 350 alert at 335 or below, and at 350 or above
/stock NVDA 120 140 shorthand: low then high
/stock add BTC-USD high 90000 one limit is fine
Each command answers with what it did:
> /stock add AAPL
Watching AAPL: price only, no alerts. Add a limit any time with /stock add AAPL low 180 high 200.
> /stock add AAPL low 335 high 350
Watching AAPL, alert at or below 335.00, alert at or above 350.00.
> /stock add BTC-USD high 90000
Watching BTC-USD, alert at or above 90,000.
When a price reaches a limit you get a toast that stays for 30 seconds, and a chime. A rising chime means your high was reached, a falling chime means your low was reached.

📈 AAPL reached your high: 350.40 USD (limit 350.00)
📉 NVDA fell to your low: 118.90 USD (limit 120.00)
Say the limits with words or with two prices:
| You type | Meaning |
|---|---|
/stock add AAPL low 335 high 350 | Alert at or below 335, and at or above 350. |
/stock add AAPL high 350 | Only the high. |
/stock add AAPL below 335 | below and under mean low. |
/stock add AAPL above 350 | above and over mean high. |
/stock AAPL 350 335 | Two prices: the smaller is the low, the larger is the high. |
/stock AAPL 350 | One bare price is ambiguous, so it asks you to say low or high. |
The low must be below the high. Run the same command again to change a limit. It keeps the ticker and replaces only what you give it.
The chime is the sound. The toast always shows, so you never miss an alert.
> /stock chime off
Stock watch: chime off for every ticker. Toasts still show.
> /stock chime on
Stock watch: chime on.
> /stock AAPL chime off
Stock watch: AAPL chime off. Its toast still shows.
> /stock AAPL chime on
Stock watch: AAPL chime on.
/stock chime off and /stock chime on set it for every ticker. mute and unmute do the same thing./stock AAPL chime off and /stock AAPL chime on set it for one ticker. A ticker with its chime off shows chime off on its row in the pane.> /stock rm AAPL
Stopped watching AAPL.
/stock rm AAPL, or the Remove button on its row in the pane./stock rm AAPL and also delete its entry from the Watch list./stock opens a pane with a box to add tickers, and one pill per ticker. Each pill has a border that is green when the price is above today's open and red when it is below. The colours don't show in text, but this is the layout:

Add [ AAPL or AAPL 180 200 or NVDA high 140 ]
[Check now] [Test sound] [Mute]
╭──────────────────────────────────────────────────────────╮
│ ╭──────────────────────────────────────────────────────╮ │
│ │ AAPL 342.46 ▲0.63% [Remove] │ │ green border
│ │ low 335.00 [·······●········] high 350.00 │ │
│ ╰──────────────────────────────────────────────────────╯ │
│ ╭──────────────────────────────────────────────────────╮ │
│ │ NVDA 118.90 ▼2.10% chime off LOW HIT [Remove] │ │ red border
│ │ low 120.00 [●···············] high 140.00 │ │
│ ╰──────────────────────────────────────────────────────╯ │
│ ╭──────────────────────────────────────────────────────╮ │
│ │ BTC-USD 83,094.31 ▲1.40% [Remove] │ │ green border, no limits
│ ╰──────────────────────────────────────────────────────╯ │
╰──────────────────────────────────────────────────────────╯
Checked every 60s while a session is open. Prices come from Yahoo Finance and can lag.
! marks a price at a limit. A ticker with no limits has no bar.LOW HIT and HIGH HIT show after a limit alerts.| Command | Does |
|---|---|
/stock | Opens the pane. |
/stock add AAPL | Watches the price only. No limits, so no alerts and no chime. |
/stock add AAPL low 180 high 200 | Watches a ticker with limits. Either limit alone is fine. |
/stock AAPL 180 200 | Shorthand: the two prices become the low and the high, in either order. |
/stock rm AAPL | Stops watching it and removes its pill. |
/stock chime off / on | Silences or restores the chime for every ticker. Toasts always show. |
/stock AAPL chime off / on | Silences or restores the chime for one ticker. Its toast still shows. |
/stock list / check | Prints the list, or fetches prices now. |
/stock test | Plays both chimes, high then low. |
A bare /stock AAPL does nothing but explain itself. Use /stock add AAPL, so a mistyped command is never mistaken for a ticker.
Tickers use Yahoo Finance symbols: AAPL, BRK-B, BTC-USD, ^GSPC, EURUSD=X, 7203.T. Up to 20 at a time.
/plugin marketplace add troyjlorents-gh/mod-squad
/plugin install stock-watch@mod-squad
Start a new session after installing, because mods load when a session starts.
Open the plugin's settings to change these. Tickers themselves go in the Watch list, or are added with /stock.
| Setting | Default | Meaning |
|---|---|---|
watchlist | blank | Tickers to watch, separated by semicolons: AAPL 180 200; NVDA high 140; MSFT (a ticker alone is price only). Applied when a session starts and whenever the text changes. /stock changes made later are not undone. |
pollSeconds | 60 | How often prices are fetched, never under 30. |
sound | chime | chime, chime and voice (also says the ticker aloud, macOS), or off. |
statusLine | show | show puts the tickers under the prompt. hide leaves that line empty. The pane and the alerts still work. |
statusCount | 3 | How many tickers the status line shows, 1 to 5. The rest show as +N. |
/stock test to hear what your setup does.hooks/register.tsx 326 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { Watch } from '../types'
5import { CHIME_USAGE, USAGE, alertText, evaluate, fmtChange, fmtPrice, parseCommand, parseQuote, quoteUrl, rangeBar, statusText } from './watch'
6import type { Side } from './watch'
7
8const PANE = 'stock-watch'
9const MAX_WATCHES = 20
10const MIN_POLL_S = 30
11
12const watchesRef = atom({ plugin: 'stock-watch', key: 'watches' } as const, [] as Watch[])
13const mutedRef = atom({ plugin: 'stock-watch', key: 'muted' } as const, false)
14const pollRef = atom({ plugin: 'stock-watch', key: 'poll' } as const, { isRunning: false, at: 0 })
15
16type Options = { pollSeconds?: number; sound?: string; watchlist?: string; statusLine?: string; statusCount?: number }
17type Cfg = { pollMs: number; sound: 'chime' | 'voice' | 'off'; watchlist: string; statusLine: boolean; statusCount: number; isPolling: boolean; isBusy: boolean }
18
19function config(options: unknown): Cfg {
20 const o = (options ?? {}) as Options
21 const s = Number(o.pollSeconds)
22 return {
23 pollMs: Math.max(MIN_POLL_S, Number.isFinite(s) && s > 0 ? s : 60) * 1000,
24 sound: o.sound === 'off' ? 'off' : o.sound === 'chime and voice' ? 'voice' : 'chime',
25 watchlist: (o.watchlist ?? '').trim(),
26 statusLine: o.statusLine !== 'hide',
27 statusCount: Math.min(5, Math.max(1, Math.round(Number(o.statusCount)) || 3)),
28 isPolling: false,
29 isBusy: false,
30 }
31}
32
33/* ----------------------------------------------------------------------- state */
34
35// The store is shared by every session on this machine, so each change reads it fresh and writes it back.
36async function stored($: EngineInterface): Promise<Watch[]> {
37 const v = await $.store.get('watches').catch(() => undefined)
38 return Array.isArray(v) ? (v as Watch[]) : []
39}
40
41// The status line settings, read by save() and load(), which are called from places that do not carry the config.
42let statusView = { show: true, count: 3 }
43
44async function save($: EngineInterface, ws: Watch[]) {
45 await $.store.set('watches', ws).catch(() => undefined)
46 await update($, watchesRef, () => ws)
47 if (statusView.show) $.ui.status(statusText(ws, statusView.count))
48}
49
50/** Loads the store into the pane's state (a session that just started, or another session's change). */
51async function load($: EngineInterface) {
52 const ws = await stored($)
53 await update($, watchesRef, () => ws)
54 await update($, mutedRef, () => false)
55 if ((await $.store.get('muted').catch(() => undefined)) === true) await update($, mutedRef, () => true)
56 const line = statusView.show ? statusText(ws, statusView.count) : undefined
57 if (line !== undefined) $.ui.status(line)
58}
59
60/* ----------------------------------------------------------------------- sound */
61
62/** Plays a clip. macOS plays through the engine; Windows has no engine player, so PowerShell plays it. */
63async function chime($: EngineInterface, side: Side) {
64 const asset = side === 'high' ? 'sounds/high.wav' : 'sounds/low.wav'
65 const isWindows = (await $.env.get('OS').catch(() => undefined)) === 'Windows_NT'
66 if (isWindows) {
67 try {
68 const path = `${$.plugin.root}/${asset}`.replace(/\//g, '\\').replace(/'/g, "''")
69 const r = await $.process.run(['powershell', '-NoProfile', '-NonInteractive', '-Command', `(New-Object Media.SoundPlayer '${path}').PlaySync()`], { timeoutMs: 15_000 })
70 if (r.exitCode === 0) return
71 } catch {
72 // No process (the desktop app): fall through to the engine's player.
73 }
74 }
75 try {
76 await $.audio.play({ asset })
77 } catch {
78 // No player here: the toast still shows.
79 }
80}
81
82async function announce($: EngineInterface, cfg: Cfg, w: Watch, side: Side, price: number) {
83 $.ui.toast(alertText(w, side, price), { timeoutMs: 30_000 })
84 if (cfg.sound === 'off' || w.quiet === true || (await read($, mutedRef))) return
85 await chime($, side)
86 if (cfg.sound === 'voice') await $.audio.speak(`${w.sym.replace(/[^A-Z0-9]/g, ' ')} ${side === 'high' ? 'reached your high' : 'fell to your low'}`).catch(() => undefined)
87}
88
89/* ------------------------------------------------------------------------ poll */
90
91async function fetchQuote($: EngineInterface, sym: string) {
92 try {
93 const r = await $.http.fetch(quoteUrl(sym), { headers: { 'User-Agent': 'Mozilla/5.0 (compatible; stock-watch)', Accept: 'application/json' } })
94 if (r.status === 429) return { error: 'rate-limited by the price service; will retry' }
95 if (!r.ok && r.text.trim() === '') return { error: `price service returned ${r.status}` }
96 return parseQuote(r.text)
97 } catch (e) {
98 return { error: e instanceof Error ? e.message : 'could not reach the price service' }
99 }
100}
101
102/** Fetches every watched ticker once, records the price, and alerts on a fresh crossing. */
103async function check($: EngineInterface, cfg: Cfg) {
104 if (cfg.isBusy) return
105 cfg.isBusy = true
106 try {
107 await update($, pollRef, p => ({ ...p, isRunning: true }))
108 for (const { sym } of await stored($)) {
109 const q = await fetchQuote($, sym)
110 // Another session may have changed or alerted since the loop began: re-read before deciding.
111 const ws = await stored($)
112 const i = ws.findIndex(w => w.sym === sym)
113 const w = ws[i]
114 if (w === undefined) continue
115 if ('error' in q) {
116 ws[i] = { ...w, error: q.error }
117 await save($, ws)
118 continue
119 }
120 const { side, alert } = evaluate(w, q.price)
121 ws[i] = { ...w, price: q.price, prev: q.prev, open: q.open ?? w.open, name: q.name ?? w.name, currency: q.currency ?? w.currency, at: await $.clock.now(), error: undefined, fired: side ?? undefined }
122 // Written before the sound plays, so a second session polling right now sees it already fired.
123 await save($, ws)
124 if (alert !== null) await announce($, cfg, ws[i] as Watch, alert, q.price)
125 }
126 } finally {
127 await update($, pollRef, () => ({ isRunning: false, at: 0 })).catch(() => undefined)
128 cfg.isBusy = false
129 }
130}
131
132function tick($: EngineInterface, cfg: Cfg) {
133 $.clock.after(cfg.pollMs, () => {
134 void check($, cfg)
135 .catch(() => undefined)
136 .then(() => tick($, cfg))
137 })
138}
139
140/** Starts the checking loop once per session. */
141function start($: EngineInterface, cfg: Cfg) {
142 if (cfg.isPolling) return
143 cfg.isPolling = true
144 $.clock.after(2_000, () => void check($, cfg).catch(() => undefined))
145 tick($, cfg)
146}
147
148/* ------------------------------------------------------------------ watch list */
149
150/**
151 * Adds or updates every ticker in the settings' watch list. Applied only when the text changed since it was last
152 * applied, so a later /stock change is not undone by the next session. Entries are never removed from here.
153 */
154async function applyWatchlist($: EngineInterface, cfg: Cfg) {
155 if (cfg.watchlist === '') return
156 if ((await $.store.get('watchlistApplied').catch(() => undefined)) === cfg.watchlist) return
157 const ws = await stored($)
158 const bad: string[] = []
159 for (const entry of cfg.watchlist.split(/[;\n]+/).map(x => x.trim()).filter(Boolean)) {
160 const cmd = parseCommand(`add ${entry}`)
161 if (cmd.kind !== 'add') {
162 bad.push(entry)
163 continue
164 }
165 const i = ws.findIndex(w => w.sym === cmd.sym)
166 const prior = i >= 0 ? ws[i] : undefined
167 const next: Watch = { ...(prior ?? { sym: cmd.sym }), low: cmd.low ?? prior?.low, high: cmd.high ?? prior?.high }
168 // A changed limit re-arms; an unchanged one keeps its state.
169 if (prior === undefined || prior.low !== next.low || prior.high !== next.high) next.fired = undefined
170 if (i >= 0) ws[i] = next
171 else if (ws.length < MAX_WATCHES) ws.push(next)
172 }
173 await save($, ws)
174 await $.store.set('watchlistApplied', cfg.watchlist).catch(() => undefined)
175 if (bad.length > 0) $.ui.toast(`stock-watch: could not read ${bad.map(b => `"${b}"`).join(', ')} in the watch list. Use: AAPL 180 200; NVDA high 140`, { timeoutMs: 15_000 })
176}
177
178/* -------------------------------------------------------------------- commands */
179
180async function run($: EngineInterface, cfg: Cfg, args: string): Promise<string> {
181 const cmd = parseCommand(args)
182 switch (cmd.kind) {
183 case 'error':
184 return cmd.text
185 case 'open':
186 case 'list': {
187 const ws = await stored($)
188 await update($, watchesRef, () => ws)
189 if (cmd.kind === 'open') return ws.length === 0 ? `Stock watch: nothing watched yet. ${USAGE}` : `Stock watch: ${ws.length} ticker${ws.length === 1 ? '' : 's'}.`
190 return ws.length === 0 ? 'Stock watch: nothing watched yet.' : ws.map(w => `${w.sym} ${fmtPrice(w.price)} low ${fmtPrice(w.low)} high ${fmtPrice(w.high)}${w.error ? ` (${w.error})` : ''}`).join('\n')
191 }
192 case 'add': {
193 const ws = await stored($)
194 const i = ws.findIndex(w => w.sym === cmd.sym)
195 if (i < 0 && ws.length >= MAX_WATCHES) return `Stock watch: up to ${MAX_WATCHES} tickers.`
196 const prior = i >= 0 ? ws[i] : undefined
197 // A changed limit starts fresh: the price may already be past it, and that should alert.
198 const next: Watch = { ...(prior ?? { sym: cmd.sym }), low: cmd.low ?? prior?.low, high: cmd.high ?? prior?.high, fired: undefined }
199 if (next.low !== undefined && next.high !== undefined && next.low >= next.high) return `The low (${next.low}) must be below the high (${next.high}).`
200 if (i >= 0) ws[i] = next
201 else ws.push(next)
202 await save($, ws)
203 $.clock.after(1, () => void check($, cfg).catch(() => undefined))
204 if (next.low === undefined && next.high === undefined) return `Watching ${next.sym}: price only, no alerts. Add a limit any time with /stock add ${next.sym} low 180 high 200.`
205 return `Watching ${next.sym}${next.low !== undefined ? `, alert at or below ${fmtPrice(next.low)}` : ''}${next.high !== undefined ? `, alert at or above ${fmtPrice(next.high)}` : ''}.`
206 }
207 case 'rm': {
208 const ws = await stored($)
209 if (!ws.some(w => w.sym === cmd.sym)) return `Stock watch: ${cmd.sym} is not on the list.`
210 await save($, ws.filter(w => w.sym !== cmd.sym))
211 return `Stopped watching ${cmd.sym}.`
212 }
213 case 'check':
214 $.clock.after(1, () => void check($, cfg).catch(() => undefined))
215 return 'Stock watch: checking prices now.'
216 case 'test':
217 await chime($, 'high')
218 await chime($, 'low')
219 return 'Stock watch: played the high chime, then the low chime. Silent? That surface has no player; the toast still shows.'
220 case 'mute':
221 case 'unmute':
222 case 'chime': {
223 const m = cmd.kind === 'mute' || (cmd.kind === 'chime' && !cmd.on)
224 await update($, mutedRef, () => m)
225 await $.store.set('muted', m).catch(() => undefined)
226 return m ? 'Stock watch: chime off for every ticker. Toasts still show.' : 'Stock watch: chime on.'
227 }
228 case 'quiet': {
229 const ws = await stored($)
230 if (!ws.some(w => w.sym === cmd.sym)) return `Stock watch: ${cmd.sym} is not on the list.`
231 await save($, ws.map(w => (w.sym === cmd.sym ? { ...w, quiet: cmd.quiet } : w)))
232 return cmd.quiet ? `Stock watch: ${cmd.sym} chime off. Its toast still shows.` : `Stock watch: ${cmd.sym} chime on.`
233 }
234 }
235}
236
237const OPEN = { id: PANE, title: 'Stock watch', focus: true, closeOnEscape: true, rows: 22, columns: 84 } as const
238
239export const register: Register = (on, options) => {
240 const cfg = config(options)
241 statusView = { show: cfg.statusLine, count: cfg.statusCount }
242
243 on('session.start', async ($, e, next) => {
244 await $.command.register({
245 name: 'stock',
246 description: 'Stock watch: alert me when a ticker hits a low or high',
247 argumentHint: 'add AAPL low 180 high 200 | rm AAPL | list | check | test | mute',
248 immediate: true,
249 })
250 await load($)
251 await applyWatchlist($, cfg)
252 start($, cfg)
253 return next(e)
254 }).catch(($, e, next) => next(e))
255
256 // A session that missed session.start still begins polling on its first prompt.
257 on('prompt.submit', async ($, e, next) => {
258 start($, cfg)
259 return next(e)
260 }).catch(($, e, next) => next(e))
261
262 on('command.run', { command: 'stock' }, async ($, e) => {
263 start($, cfg)
264 const text = await run($, cfg, e.args)
265 if (parseCommand(e.args).kind === 'open') await $.ui.open(OPEN).catch(() => undefined)
266 return { text }
267 })
268
269 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
270 const { Box, Text, Button, Input } = $.ui.resolve(e)
271 const ws = await read($, watchesRef)
272 const muted = await read($, mutedRef)
273 const cols = Math.max(20, e.props.bodyColumns)
274
275 return (
276 <Box flexDirection="column" width={cols}>
277 <Input key="add" label="Add" placeholder="AAPL or AAPL 180 200 or NVDA high 140" onSubmit={value => void run($, cfg, `add ${value}`).then(t => $.ui.toast(t))} />
278 <Box columnGap={1} marginTop={1}>
279 <Button key="check" label="Check now" hotkey="c" variant="secondary" onPress={() => void run($, cfg, 'check')} />
280 <Button key="test" label="Test sound" hotkey="t" variant="secondary" onPress={() => void run($, cfg, 'test')} />
281 <Button key="mute" label={muted ? 'Unmute' : 'Mute'} hotkey="m" variant={muted ? 'primary' : 'secondary'} onPress={() => void run($, cfg, muted ? 'unmute' : 'mute')} />
282 </Box>
283 <Box flexDirection="column" marginTop={1} borderStyle="round" borderColor="#22c55e" paddingX={1} width={cols}>
284 {ws.length === 0 && (
285 <Text dimColor wrap="wrap">
286 Nothing watched yet. Type a ticker above, for example AAPL to watch the price, or AAPL 180 200 to alert at a low and a high.
287 </Text>
288 )}
289 {ws.map(w => {
290 // Up or down from today's open (the previous close when the open is not known yet).
291 const ch = fmtChange(w.price, w.open ?? w.prev)
292 const tone = ch.dir > 0 ? '#22c55e' : ch.dir < 0 ? '#ef4444' : undefined
293 return (
294 <Box key={w.sym} flexDirection="column" marginBottom={1} borderStyle="round" borderColor={tone} paddingX={1} width={cols - 2}>
295 <Box columnGap={1}>
296 <Text bold>{w.sym.padEnd(8)}</Text>
297 <Text bold>{fmtPrice(w.price)}</Text>
298 <Text color={tone} dimColor={ch.dir === 0}>
299 {ch.text}
300 </Text>
301 {w.quiet === true && <Text dimColor>chime off</Text>}
302 {w.fired !== undefined && <Text color={w.fired === 'high' ? '#22c55e' : '#ef4444'} bold>{w.fired === 'high' ? 'HIGH HIT' : 'LOW HIT'}</Text>}
303 <Button key={`rm-${w.sym}`} label="Remove" variant="secondary" onPress={() => void run($, cfg, `rm ${w.sym}`)} />
304 </Box>
305 {(w.low !== undefined || w.high !== undefined) && (
306 <Text dimColor wrap="truncate">
307 {`low ${fmtPrice(w.low)} ${rangeBar(w)} high ${fmtPrice(w.high)}`}
308 </Text>
309 )}
310 {w.error !== undefined && (
311 <Text color="#f59e0b" wrap="truncate">
312 {w.error}
313 </Text>
314 )}
315 </Box>
316 )
317 })}
318 </Box>
319 <Text dimColor wrap="wrap">
320 {`Checked every ${Math.round(cfg.pollMs / 1000)}s while a session is open. Prices come from Yahoo Finance and can lag.`}
321 </Text>
322 </Box>
323 )
324 })
325}
326hooks/watch.ts 156 lines1import type { Watch } from '../types'
2
3export type Side = 'low' | 'high'
4
5export type Cmd =
6 | { kind: 'open' }
7 | { kind: 'add'; sym: string; low?: number; high?: number }
8 | { kind: 'rm'; sym: string }
9 | { kind: 'chime'; on: boolean }
10 | { kind: 'quiet'; sym: string; quiet: boolean }
11 | { kind: 'list' | 'check' | 'test' | 'mute' | 'unmute' }
12 | { kind: 'error'; text: string }
13
14export const CHIME_USAGE = 'Usage: /stock chime on | off (every ticker) · /stock AAPL chime on | off (one ticker)'
15
16export const USAGE =
17 'Usage: /stock add AAPL low 180 high 200 · /stock AAPL 180 200 (low, high) · /stock add AAPL (price only) · /stock rm AAPL · /stock list | check | test | mute | unmute'
18
19const VERBS = new Set(['add', 'rm', 'remove', 'delete', 'list', 'check', 'refresh', 'test', 'mute', 'unmute'])
20const LOW_WORDS = new Set(['low', 'below', 'under', '<', '<=', 'lo'])
21const HIGH_WORDS = new Set(['high', 'above', 'over', '>', '>=', 'hi'])
22// AAPL, BRK-B, BTC-USD, ^GSPC, EURUSD=X, 7203.T
23const SYMBOL = /^[A-Z0-9^][A-Z0-9.=^-]{0,11}$/
24const NUMBER = /^\$?\d[\d,]*\.?\d*$/
25
26export const toNumber = (t: string): number | null => (NUMBER.test(t) ? Number(t.replace(/[$,]/g, '')) : null)
27
28/** Parses what follows `/stock` (or what is typed in the pane's box). */
29export function parseCommand(args: string): Cmd {
30 const tokens = args.trim().split(/\s+/).filter(Boolean)
31 if (tokens.length === 0) return { kind: 'open' }
32 const verb = (tokens[0] ?? '').toLowerCase()
33 if (verb === 'chime') {
34 const v = (tokens[1] ?? '').toLowerCase()
35 return v === 'on' || v === 'off' ? { kind: 'chime', on: v === 'on' } : { kind: 'error', text: CHIME_USAGE }
36 }
37 // /stock AAPL chime off: silences one ticker's chime; its toast still shows.
38 if (tokens.length === 3 && (tokens[1] ?? '').toLowerCase() === 'chime') {
39 const sym = verb.toUpperCase()
40 const v = (tokens[2] ?? '').toLowerCase()
41 if (!SYMBOL.test(sym) || (v !== 'on' && v !== 'off')) return { kind: 'error', text: CHIME_USAGE }
42 return { kind: 'quiet', sym, quiet: v === 'off' }
43 }
44 if (verb === 'list' || verb === 'check' || verb === 'test' || verb === 'mute' || verb === 'unmute') return { kind: verb }
45 if (verb === 'refresh') return { kind: 'check' }
46
47 const rest = VERBS.has(verb) ? tokens.slice(1) : tokens
48 const sym = (rest[0] ?? '').toUpperCase()
49 if (verb === 'rm' || verb === 'remove' || verb === 'delete') return SYMBOL.test(sym) ? { kind: 'rm', sym } : { kind: 'error', text: USAGE }
50 if (!SYMBOL.test(sym) || toNumber(sym) !== null) return { kind: 'error', text: USAGE }
51
52 let low: number | undefined
53 let high: number | undefined
54 const bare: number[] = []
55 for (let i = 1; i < rest.length; i++) {
56 const t = (rest[i] ?? '').toLowerCase()
57 const side = LOW_WORDS.has(t) ? 'low' : HIGH_WORDS.has(t) ? 'high' : null
58 if (side !== null) {
59 const n = toNumber(rest[i + 1] ?? '')
60 if (n === null) return { kind: 'error', text: `"${t}" needs a price after it. ${USAGE}` }
61 if (side === 'low') low = n
62 else high = n
63 i++
64 continue
65 }
66 const n = toNumber(t)
67 if (n === null) return { kind: 'error', text: `Did not understand "${rest[i]}". ${USAGE}` }
68 bare.push(n)
69 }
70 if (bare.length > 2) return { kind: 'error', text: USAGE }
71 if (bare.length === 1) return { kind: 'error', text: `One bare price is ambiguous: say "low ${bare[0]}" or "high ${bare[0]}".` }
72 if (bare.length === 2 && low === undefined && high === undefined) [low, high] = [Math.min(...bare), Math.max(...bare)]
73 // Price-only needs the explicit `add`, so a mistyped command is not mistaken for a ticker.
74 if (low === undefined && high === undefined && verb !== 'add') return { kind: 'error', text: `To just watch ${sym}'s price, use /stock add ${sym}. ${USAGE}` }
75 if (low !== undefined && high !== undefined && low >= high) return { kind: 'error', text: `The low (${low}) must be below the high (${high}).` }
76 return { kind: 'add', sym, low, high }
77}
78
79/* -------------------------------------------------------------------- quotes */
80
81export type Quote = { price: number; prev?: number; open?: number; name?: string; currency?: string }
82
83/** Reads Yahoo Finance's chart response. Defensive: any other shape is an error message, never a throw. */
84export function parseQuote(text: string): Quote | { error: string } {
85 let j: any
86 try {
87 j = JSON.parse(text)
88 } catch {
89 return { error: 'the price service did not answer with data (it may be rate-limiting)' }
90 }
91 const err = j?.chart?.error
92 if (err) return { error: String(err.description ?? err.code ?? 'unknown symbol') }
93 const m = j?.chart?.result?.[0]?.meta
94 const price = m?.regularMarketPrice
95 if (typeof price !== 'number' || !Number.isFinite(price)) return { error: 'no price in the response' }
96 const prev = typeof m.chartPreviousClose === 'number' ? m.chartPreviousClose : typeof m.previousClose === 'number' ? m.previousClose : undefined
97 // Today's open is the latest daily candle's open; the previous close is the fallback baseline.
98 const opens: unknown[] = j.chart.result[0].indicators?.quote?.[0]?.open ?? []
99 const open = [...opens].reverse().find((v): v is number => typeof v === 'number' && Number.isFinite(v))
100 return { price, prev, open, name: m.longName ?? m.shortName, currency: m.currency }
101}
102
103export const quoteUrl = (sym: string) => `https://query1.finance.yahoo.com/v8/finance/chart/${encodeURIComponent(sym)}?interval=1d&range=1d`
104
105/* ------------------------------------------------------------------- alerting */
106
107/**
108 * What a new price does to a watch: which limit it is past (null inside the band), and whether that is a
109 * fresh crossing worth an alert. A limit alerts once; it re-arms when the price comes back inside the band.
110 */
111export function evaluate(w: Watch, price: number): { side: Side | null; alert: Side | null } {
112 const side: Side | null = w.low !== undefined && price <= w.low ? 'low' : w.high !== undefined && price >= w.high ? 'high' : null
113 return { side, alert: side !== null && w.fired !== side ? side : null }
114}
115
116/* ----------------------------------------------------------------- formatting */
117
118export const fmtPrice = (n: number | undefined): string =>
119 n === undefined ? '-' : n >= 1000 ? n.toLocaleString('en-US', { maximumFractionDigits: 2 }) : n.toFixed(n >= 1 ? 2 : 4)
120
121export function fmtChange(price: number | undefined, prev: number | undefined): { text: string; dir: 1 | -1 | 0 } {
122 if (price === undefined || prev === undefined || prev === 0) return { text: '', dir: 0 }
123 const pct = ((price - prev) / prev) * 100
124 return { text: `${pct >= 0 ? '▲' : '▼'}${Math.abs(pct).toFixed(2)}%`, dir: pct > 0 ? 1 : pct < 0 ? -1 : 0 }
125}
126
127/** `[···●······]` with the price's place between the low and the high; `!` at a limit. */
128export function rangeBar(w: Watch, width = 16): string {
129 if (w.price === undefined || w.low === undefined || w.high === undefined) return ''
130 const t = Math.min(1, Math.max(0, (w.price - w.low) / (w.high - w.low)))
131 const at = Math.round(t * (width - 1))
132 return `[${Array.from({ length: width }, (_, i) => (i === at ? (t === 0 || t === 1 ? '!' : '●') : '·')).join('')}]`
133}
134
135/** Prices without cents once they are large (83,094 not 83,094.31): the status line is narrow. */
136export const fmtShort = (n: number | undefined): string => (n !== undefined && n >= 1000 ? n.toLocaleString('en-US', { maximumFractionDigits: 0 }) : fmtPrice(n))
137
138/**
139 * The pinned line under the prompt: the first few tickers with ▲ or ▼ from today's open (the previous close
140 * until the open is known), HIGH or LOW once a limit has been hit, and +N for the ones not shown.
141 */
142export function statusText(ws: Watch[], count = 3): string | undefined {
143 const shown = ws.filter(w => w.price !== undefined).slice(0, count)
144 if (shown.length === 0) return undefined
145 const parts = shown.map(w => {
146 const dir = fmtChange(w.price, w.open ?? w.prev).dir
147 return `${w.sym} ${fmtShort(w.price)}${dir > 0 ? ' ▲' : dir < 0 ? ' ▼' : ''}${w.fired === undefined ? '' : w.fired === 'high' ? ' HIGH' : ' LOW'}`
148 })
149 return `${parts.join(' · ')}${ws.length > shown.length ? ` +${ws.length - shown.length}` : ''}`
150}
151
152export function alertText(w: Watch, side: Side, price: number): string {
153 const limit = side === 'high' ? w.high : w.low
154 return `${side === 'high' ? '📈' : '📉'} ${w.sym} ${side === 'high' ? 'reached your high' : 'fell to your low'}: ${fmtPrice(price)}${w.currency ? ` ${w.currency}` : ''} (limit ${fmtPrice(limit)})`
155}
156types/index.d.ts 32 lines1/** One ticker being watched. Stored in the plugin's store, shared by every session on the machine. */
2export type Watch = {
3 sym: string
4 low?: number
5 high?: number
6 /** Which limit already alerted. Cleared when the price is back inside the band, which re-arms it. */
7 fired?: 'low' | 'high'
8 price?: number
9 /** Today's open, the baseline for the up/down colour. Falls back to the previous close when absent. */
10 open?: number
11 prev?: number
12 /** This ticker's chime is off. Its toast still shows. */
13 quiet?: boolean
14 name?: string
15 currency?: string
16 /** When the price was last fetched (ms since epoch). */
17 at?: number
18 /** Why the last fetch failed, if it did. */
19 error?: string
20}
21
22declare module 'claude-code' {
23 interface PluginState {
24 'stock-watch': {
25 watches: Watch[]
26 muted: boolean
27 /** The last poll: when it ended, and whether one is running now. */
28 poll: { isRunning: boolean; at: number }
29 }
30 }
31}
32