@miphamai/cli 0.20.0 → 0.20.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,19 @@
1
+ import type { TranslationMap } from './types'
2
+
3
+ function getNested(obj: TranslationMap, key: string): unknown {
4
+ return key.split('.').reduce((o, k) => (o as any)?.[k], obj as any)
5
+ }
6
+
7
+ export function createT(
8
+ current: TranslationMap,
9
+ fallback: TranslationMap,
10
+ ): (key: string, params?: Record<string, string>) => string {
11
+ return function t(key: string, params?: Record<string, string>): string {
12
+ const val = getNested(current, key) ?? getNested(fallback, key) ?? key
13
+ if (typeof val !== 'string') return key
14
+ if (params) {
15
+ return val.replace(/\{(\w+)\}/g, (_, k: string) => params[k] ?? '')
16
+ }
17
+ return val
18
+ }
19
+ }
@@ -0,0 +1,10 @@
1
+ export type Locale = 'en-US' | 'zh-CN'
2
+
3
+ export const SUPPORTED_LOCALES: Locale[] = ['en-US', 'zh-CN']
4
+
5
+ export const FALLBACK_LOCALE: Locale = 'en-US'
6
+
7
+ /** A TranslationMap is a nested object where leaves are template strings. */
8
+ export interface TranslationMap {
9
+ [key: string]: string | TranslationMap
10
+ }
package/src/index.tsx CHANGED
@@ -24,12 +24,12 @@ import { getMetrics } from './core/metrics'
24
24
  import { ARTIFACTS_DIR, ARTIFACT_PORT, MIPHAM_DIR } from './shared/constants'
25
25
  import { AgentViewManager } from './agent-view/agent-view-manager'
26
26
  import { AgentViewDashboard } from './agent-view/dashboard'
27
- import { createT } from '@mipham/shared/i18n/t'
28
- import { detectLocale } from '@mipham/shared/i18n/detect'
27
+ import { createT } from './i18n-core/t'
28
+ import { detectLocale } from './i18n-core/detect'
29
29
  import { I18nProvider } from './i18n-context'
30
- import enUS from '@mipham/shared/i18n/locales/en-US.json' with { type: 'json' }
31
- import zhCN from '@mipham/shared/i18n/locales/zh-CN.json' with { type: 'json' }
32
- import type { TranslationMap } from '@mipham/shared/i18n/types'
30
+ import enUS from './i18n-core/locales/en-US.json' with { type: 'json' }
31
+ import zhCN from './i18n-core/locales/zh-CN.json' with { type: 'json' }
32
+ import type { TranslationMap } from './i18n-core/types'
33
33
 
34
34
  interface RunOptions {
35
35
  model?: string
@@ -69,7 +69,10 @@ export async function runApp(options: RunOptions): Promise<void> {
69
69
 
70
70
  // Detect locale and create translation function
71
71
  const locale = detectLocale({ lang: options.lang })
72
- const localeBundles: Record<string, TranslationMap> = { 'en-US': enUS as TranslationMap, 'zh-CN': zhCN as TranslationMap }
72
+ const localeBundles: Record<string, TranslationMap> = {
73
+ 'en-US': enUS as TranslationMap,
74
+ 'zh-CN': zhCN as TranslationMap,
75
+ }
73
76
  const t = createT(localeBundles[locale] || enUS, enUS)
74
77
 
75
78
  // Bootstrap providers
package/src/mcp/client.ts CHANGED
@@ -10,10 +10,10 @@ import { StdioTransport } from './transport'
10
10
  import { McpProtocol } from './protocol'
11
11
  import { OAuthClient } from './oauth'
12
12
  import { TokenStore } from './token-store'
13
- import { createT } from '@mipham/shared/i18n/t'
14
- import enUS from '@mipham/shared/i18n/locales/en-US.json'
15
- import zhCN from '@mipham/shared/i18n/locales/zh-CN.json'
16
- import type { TranslationMap } from '@mipham/shared/i18n/types'
13
+ import { createT } from '../i18n-core/t'
14
+ import enUS from '../i18n-core/locales/en-US.json'
15
+ import zhCN from '../i18n-core/locales/zh-CN.json'
16
+ import type { TranslationMap } from '../i18n-core/types'
17
17
 
18
18
  const bundles: Record<string, TranslationMap> = {
19
19
  'en-US': enUS as TranslationMap,
package/src/mcp/oauth.ts CHANGED
@@ -4,10 +4,10 @@ import { createServer, Server } from 'node:http'
4
4
  import type { IncomingMessage, ServerResponse } from 'node:http'
5
5
  import type { McpServerConfig } from '../shared/types'
6
6
  import { TokenStore } from './token-store'
7
- import { createT } from '@mipham/shared/i18n/t'
8
- import enUS from '@mipham/shared/i18n/locales/en-US.json'
9
- import zhCN from '@mipham/shared/i18n/locales/zh-CN.json'
10
- import type { TranslationMap } from '@mipham/shared/i18n/types'
7
+ import { createT } from '../i18n-core/t'
8
+ import enUS from '../i18n-core/locales/en-US.json'
9
+ import zhCN from '../i18n-core/locales/zh-CN.json'
10
+ import type { TranslationMap } from '../i18n-core/types'
11
11
 
12
12
  const bundles: Record<string, TranslationMap> = {
13
13
  'en-US': enUS as TranslationMap,
@@ -9,10 +9,10 @@
9
9
  import type { ToolDefinition as McpToolDefinition, ToolCallResult } from './types'
10
10
  import type { ToolDefinition, ToolResult, ToolContext } from '../shared/types'
11
11
  import { McpClient } from './client'
12
- import { createT } from '@mipham/shared/i18n/t'
13
- import enUS from '@mipham/shared/i18n/locales/en-US.json'
14
- import zhCN from '@mipham/shared/i18n/locales/zh-CN.json'
15
- import type { TranslationMap } from '@mipham/shared/i18n/types'
12
+ import { createT } from '../i18n-core/t'
13
+ import enUS from '../i18n-core/locales/en-US.json'
14
+ import zhCN from '../i18n-core/locales/zh-CN.json'
15
+ import type { TranslationMap } from '../i18n-core/types'
16
16
 
17
17
  const bundles: Record<string, TranslationMap> = {
18
18
  'en-US': enUS as TranslationMap,
@@ -127,7 +127,11 @@ export function registerMcpServerTools(
127
127
  registered++
128
128
  } catch (err) {
129
129
  process.stderr.write(
130
- t('errors.mcp_register_failed', { tool: mcpTool.name, server: serverName, error: String(err) }) + '\n',
130
+ t('errors.mcp_register_failed', {
131
+ tool: mcpTool.name,
132
+ server: serverName,
133
+ error: String(err),
134
+ }) + '\n',
131
135
  )
132
136
  }
133
137
  }
@@ -1,9 +1,9 @@
1
1
  import type { ToolDefinition, ToolResult } from '../shared/index.ts'
2
2
  import { sanitizeParams } from '../shared/sanitize'
3
- import { createT } from '@mipham/shared/i18n/t'
4
- import enUS from '@mipham/shared/i18n/locales/en-US.json'
5
- import zhCN from '@mipham/shared/i18n/locales/zh-CN.json'
6
- import type { TranslationMap } from '@mipham/shared/i18n/types'
3
+ import { createT } from '../i18n-core/t'
4
+ import enUS from '../i18n-core/locales/en-US.json'
5
+ import zhCN from '../i18n-core/locales/zh-CN.json'
6
+ import type { TranslationMap } from '../i18n-core/types'
7
7
  import { readTool } from './file/read'
8
8
  import { writeTool } from './file/write'
9
9
  import { editTool } from './file/edit'
@@ -110,7 +110,11 @@ function withValidation(tool: ToolDefinition): ToolDefinition {
110
110
  const cleanParams = sanitizeParams(params)
111
111
  const errors = validateParams(schema, cleanParams)
112
112
  if (errors.length > 0) {
113
- return { success: false, content: '', error: t('errors.invalid_params', { errors: errors.join('; ') }) }
113
+ return {
114
+ success: false,
115
+ content: '',
116
+ error: t('errors.invalid_params', { errors: errors.join('; ') }),
117
+ }
114
118
  }
115
119
  return tool.execute(cleanParams, ctx)
116
120
  },
@@ -42,6 +42,21 @@ export const webFetchTool: ToolDefinition = {
42
42
  error: `HTTP ${response.status}: ${response.statusText}`,
43
43
  }
44
44
  }
45
+
46
+ // SSRF defense: re-validate the resolved URL after redirects.
47
+ // `redirect: 'follow'` may have landed on an internal IP — check again.
48
+ // Guard: response.url may not exist in some environments (e.g. mocked fetch).
49
+ if (response.url) {
50
+ const redirectError = validateUrl(response.url)
51
+ if (redirectError) {
52
+ return {
53
+ success: false,
54
+ content: '',
55
+ error: `Redirect blocked: ${redirectError}`,
56
+ }
57
+ }
58
+ }
59
+
45
60
  const html = await response.text()
46
61
  // Simple HTML-to-text extraction
47
62
  const text = html
package/src/ui/app.tsx CHANGED
@@ -146,6 +146,15 @@ export function App({
146
146
  const [goalText, setGoalText] = useState('')
147
147
  const [permissionMode, setPermissionMode] = useState<PermissionMode>('default')
148
148
  const abortRef = useRef<AbortController | null>(null)
149
+ // Stream buffer: accumulate text chunks and throttle state updates to ~16fps.
150
+ // Without this, every SSE chunk triggers setMessages → copies full array →
151
+ // re-renders ChatPanel → re-runs compactToolGroups O(n). At 20-50 chunks/sec
152
+ // with 200+ messages, this saturates the event loop and freezes the UI.
153
+ const streamBufferRef = useRef<{
154
+ turnContent: string
155
+ isFirst: boolean
156
+ timer: ReturnType<typeof setTimeout> | null
157
+ }>({ turnContent: '', isFirst: true, timer: null })
149
158
  const [agentProgress, setAgentProgress] = useState<AgentProgress | null>(null)
150
159
  // Multi-agent tracking: keyed by agent/task ID, shows all running + recently completed agents
151
160
  const [runningAgents, setRunningAgents] = useState<Record<string, AgentEntry>>({})
@@ -364,6 +373,25 @@ export function App({
364
373
  let turnContent = ''
365
374
  let isNewTurn = true
366
375
 
376
+ // Flush any pending stream buffer to state
377
+ const flushStreamBuffer = () => {
378
+ if (streamBufferRef.current.timer) {
379
+ clearTimeout(streamBufferRef.current.timer)
380
+ streamBufferRef.current.timer = null
381
+ }
382
+ const latest = streamBufferRef.current.turnContent
383
+ if (latest && !streamBufferRef.current.isFirst) {
384
+ setMessages((prev) => {
385
+ const updated = [...prev]
386
+ const last = updated[updated.length - 1]
387
+ if (last?.role === 'assistant') {
388
+ last.content = latest
389
+ }
390
+ return updated
391
+ })
392
+ }
393
+ }
394
+
367
395
  try {
368
396
  for await (const chunk of engine.process(input, controller.signal)) {
369
397
  // Reasoning content (DeepSeek V4 thinking mode) — silently consumed,
@@ -373,21 +401,34 @@ export function App({
373
401
  }
374
402
 
375
403
  if (chunk.type === 'text' && chunk.content) {
376
- // New turn: reset per-turn accumulator and push a fresh assistant message
404
+ // New turn: push fresh assistant message, reset stream buffer
377
405
  if (isNewTurn) {
378
406
  turnContent = chunk.content
379
407
  isNewTurn = false
380
- setMessages((prev) => [...prev, { role: 'assistant', content: turnContent }])
408
+ streamBufferRef.current = { turnContent: chunk.content, isFirst: false, timer: null }
409
+ setMessages((prev) => [
410
+ ...prev,
411
+ { role: 'assistant' as const, content: chunk.content || '' },
412
+ ])
381
413
  } else {
382
414
  turnContent += chunk.content
383
- setMessages((prev) => {
384
- const updated = [...prev]
385
- const last = updated[updated.length - 1]
386
- if (last?.role === 'assistant') {
387
- last.content = turnContent
388
- }
389
- return updated
390
- })
415
+ streamBufferRef.current.turnContent = turnContent
416
+ // Throttle: flush to state at most every 60ms (~16 fps for text).
417
+ // The ref holds the latest text; state update copies the ref value.
418
+ if (!streamBufferRef.current.timer) {
419
+ streamBufferRef.current.timer = setTimeout(() => {
420
+ streamBufferRef.current.timer = null
421
+ const latest = streamBufferRef.current.turnContent
422
+ setMessages((prev) => {
423
+ const updated = [...prev]
424
+ const last = updated[updated.length - 1]
425
+ if (last?.role === 'assistant') {
426
+ last.content = latest
427
+ }
428
+ return updated
429
+ })
430
+ }, 60)
431
+ }
391
432
  }
392
433
  assistantContent += chunk.content
393
434
  }
@@ -417,6 +458,8 @@ export function App({
417
458
  toolMeta: { name: toolName, input: detail, collapsed: true },
418
459
  },
419
460
  ])
461
+ // Flush stream buffer before showing tool card
462
+ flushStreamBuffer()
420
463
  // Mark that next text chunk starts a new turn
421
464
  isNewTurn = true
422
465
  }
@@ -487,6 +530,8 @@ export function App({
487
530
  } catch (err) {
488
531
  setMessages((prev) => [...prev, { role: 'system', content: `Error: ${String(err)}` }])
489
532
  } finally {
533
+ // Flush any remaining stream buffer before finishing
534
+ flushStreamBuffer()
490
535
  setIsLoading(false)
491
536
  abortRef.current = null
492
537
  // Auto-save checkpoint after each AI response
package/src/ui/chat.tsx CHANGED
@@ -1,4 +1,4 @@
1
- import React from 'react'
1
+ import React, { useMemo } from 'react'
2
2
  import { Box, Text } from 'ink'
3
3
  import { useI18n } from '../i18n-context'
4
4
  import type { ChatMessage } from './app'
@@ -96,16 +96,22 @@ const MessageRow = React.memo(
96
96
 
97
97
  export function ChatPanel({ messages, focusMode }: ChatPanelProps) {
98
98
  const { t } = useI18n()
99
- // Group consecutive tool calls into compact summaries to reduce visual noise.
100
- // In focus mode, use the more aggressive compactForFocus.
101
- const displayMessages = focusMode ? compactForFocus(messages) : compactToolGroups(messages)
99
+ // Memoize display message computation to avoid O(n) compact on every render.
100
+ // During streaming, messages changes on every chunk; without memoization,
101
+ // compactToolGroups/compactForFocus re-iterates the full message list on each
102
+ // chunk, saturating the event loop at 20-50 chunks/sec.
103
+ const displayMessages = useMemo(
104
+ () => (focusMode ? compactForFocus(messages) : compactToolGroups(messages)),
105
+ [messages, focusMode],
106
+ )
102
107
 
103
108
  return (
104
109
  <Box flexDirection="column" marginY={1} flexGrow={1}>
105
110
  {focusMode && messages.length > 0 && (
106
111
  <Box marginBottom={1}>
107
112
  <Text dimColor>
108
- 🔍 {t('ui.chat.focus_hint')} — {countHidden(messages, displayMessages, t)} · {t('ui.chat.toggle_focus_hint')} · {t('ui.chat.expand_hint')}
113
+ 🔍 {t('ui.chat.focus_hint')} — {countHidden(messages, displayMessages, t)} ·{' '}
114
+ {t('ui.chat.toggle_focus_hint')} · {t('ui.chat.expand_hint')}
109
115
  </Text>
110
116
  </Box>
111
117
  )}
@@ -124,14 +130,10 @@ export function ChatPanel({ messages, focusMode }: ChatPanelProps) {
124
130
  </Text>
125
131
  </Box>
126
132
  <Box marginTop={1}>
127
- <Text dimColor>
128
- {t('ui.banner.controls_hint')}
129
- </Text>
133
+ <Text dimColor>{t('ui.banner.controls_hint')}</Text>
130
134
  </Box>
131
135
  <Box marginTop={1}>
132
- <Text dimColor>
133
- {t('ui.banner.tip_clear')}
134
- </Text>
136
+ <Text dimColor>{t('ui.banner.tip_clear')}</Text>
135
137
  </Box>
136
138
  </Box>
137
139
  )}
@@ -243,7 +245,11 @@ function summarizeToolGroup(tools: ChatMessage[]): ChatMessage {
243
245
  }
244
246
 
245
247
  /** Count how many messages are hidden in focus mode. */
246
- function countHidden(all: ChatMessage[], compacted: ChatMessage[], t: (key: string, params?: Record<string, string>) => string): string {
248
+ function countHidden(
249
+ all: ChatMessage[],
250
+ compacted: ChatMessage[],
251
+ t: (key: string, params?: Record<string, string>) => string,
252
+ ): string {
247
253
  const toolCount = all.filter((m) => m.toolMeta).length
248
254
  const hidden = all.length - compacted.length
249
255
  if (hidden <= 0) return t('ui.chat.showing_all', { count: String(all.length) })
@@ -101,7 +101,9 @@ export function CommandPicker({
101
101
 
102
102
  {/* Command list */}
103
103
  <Box flexDirection="column" marginBottom={1}>
104
- {visible.length === 0 && <Text dimColor> {t('ui.command_picker.no_matches', { filter })}</Text>}
104
+ {visible.length === 0 && (
105
+ <Text dimColor> {t('ui.command_picker.no_matches', { filter })}</Text>
106
+ )}
105
107
  {visible.map((cmd, i) => {
106
108
  const globalIdx = i + scrollStart
107
109
  const isCursor = globalIdx === cursorIdx
@@ -13,10 +13,10 @@ import { McpClient } from '../mcp/client'
13
13
  import { NPM_UPDATE_COMMAND, PACKAGE_VERSION } from '../shared/index.ts'
14
14
  import { getPreference } from '../config/preferences'
15
15
  import { stripIndent } from './strip-indent.js'
16
- import { createT } from '@mipham/shared/i18n/t'
17
- import type { TranslationMap } from '@mipham/shared/i18n/types'
18
- import enUS from '@mipham/shared/i18n/locales/en-US.json' with { type: 'json' }
19
- import zhCN from '@mipham/shared/i18n/locales/zh-CN.json' with { type: 'json' }
16
+ import { createT } from '../i18n-core/t'
17
+ import type { TranslationMap } from '../i18n-core/types'
18
+ import enUS from '../i18n-core/locales/en-US.json' with { type: 'json' }
19
+ import zhCN from '../i18n-core/locales/zh-CN.json' with { type: 'json' }
20
20
 
21
21
  // Default t() fallback for plain-function contexts (tests, bootstrap).
22
22
  // When called from the React tree, ctx.t is populated from useI18n().
@@ -351,7 +351,9 @@ const modelsCmd: CommandHandler = (ctx) => {
351
351
 
352
352
  const providerCmd: CommandHandler = (ctx) => {
353
353
  const t = resolveT(ctx)
354
- return { content: t('commands.provider.current', { provider: ctx.providerId, model: ctx.modelId }) }
354
+ return {
355
+ content: t('commands.provider.current', { provider: ctx.providerId, model: ctx.modelId }),
356
+ }
355
357
  }
356
358
 
357
359
  const providersCmd: CommandHandler = (ctx) => {
@@ -417,7 +419,9 @@ const toolsCmd: CommandHandler = (ctx) => {
417
419
  .filter(([, v]) => v!.length > 0)
418
420
  .map(([cat, items]) => `── ${cat.toUpperCase()} ──\n${items!.join('\n')}`)
419
421
 
420
- return { content: `${t('commands.tools.title', { count: String(tools.size) })}\n\n${sections.join('\n\n')}` }
422
+ return {
423
+ content: `${t('commands.tools.title', { count: String(tools.size) })}\n\n${sections.join('\n\n')}`,
424
+ }
421
425
  }
422
426
 
423
427
  const skillsCmd: CommandHandler = (ctx) => {
@@ -548,10 +552,17 @@ const goalCmd: CommandHandler = (ctx, args) => {
548
552
  if (!state.goal) {
549
553
  return { content: t('commands.goal.usage') }
550
554
  }
551
- const lines = [t('commands.goal.status_title'), '', t('commands.goal.status_goal', { goal: state.goal })]
552
- if (state.verifyScript) lines.push(t('commands.goal.status_verify_script', { script: state.verifyScript }))
553
- if (state.verifySkill) lines.push(t('commands.goal.status_verify_skill', { skill: state.verifySkill }))
554
- if (state.decompose) lines.push(t('commands.goal.status_decompose', { count: String(state.subtasks.length) }))
555
+ const lines = [
556
+ t('commands.goal.status_title'),
557
+ '',
558
+ t('commands.goal.status_goal', { goal: state.goal }),
559
+ ]
560
+ if (state.verifyScript)
561
+ lines.push(t('commands.goal.status_verify_script', { script: state.verifyScript }))
562
+ if (state.verifySkill)
563
+ lines.push(t('commands.goal.status_verify_skill', { skill: state.verifySkill }))
564
+ if (state.decompose)
565
+ lines.push(t('commands.goal.status_decompose', { count: String(state.subtasks.length) }))
555
566
  lines.push('', t('commands.goal.status_clear_hint'))
556
567
  return { content: lines.join('\n') }
557
568
  }
@@ -670,10 +681,15 @@ const usageCmd: CommandHandler = (ctx) => {
670
681
  const apiTotal = summary.apiInputTokens + summary.apiOutputTokens
671
682
  const apiLine =
672
683
  summary.apiInputTokens > 0 || summary.apiOutputTokens > 0
673
- ? t('commands.usage.api_tokens', { in: summary.apiInputTokens.toLocaleString(), out: summary.apiOutputTokens.toLocaleString(), total: apiTotal.toLocaleString() })
684
+ ? t('commands.usage.api_tokens', {
685
+ in: summary.apiInputTokens.toLocaleString(),
686
+ out: summary.apiOutputTokens.toLocaleString(),
687
+ total: apiTotal.toLocaleString(),
688
+ })
674
689
  : t('commands.usage.no_api')
675
690
 
676
- const toolSection = toolLines.length > 0 ? `\n${t('commands.usage.tool_section')}\n${toolLines.join('\n')}` : ''
691
+ const toolSection =
692
+ toolLines.length > 0 ? `\n${t('commands.usage.tool_section')}\n${toolLines.join('\n')}` : ''
677
693
 
678
694
  return {
679
695
  content: stripIndent`
@@ -976,7 +992,11 @@ const rewindCmd: CommandHandler = (ctx) => {
976
992
  }
977
993
 
978
994
  return {
979
- content: t('commands.rewind.confirmed', { label: result.label, count: String(result.messageCount), remaining: String(c.getCheckpoints().length) }),
995
+ content: t('commands.rewind.confirmed', {
996
+ label: result.label,
997
+ count: String(result.messageCount),
998
+ remaining: String(c.getCheckpoints().length),
999
+ }),
980
1000
  clearMessages: true,
981
1001
  }
982
1002
  }
@@ -1164,7 +1184,11 @@ const branchCmd: CommandHandler = (ctx, args) => {
1164
1184
 
1165
1185
  const checkpointId = c.saveCheckpoint(name)
1166
1186
  return {
1167
- content: t('commands.branch.details', { name, checkpoint: String(checkpointId), messages: String(msgs.length) }),
1187
+ content: t('commands.branch.details', {
1188
+ name,
1189
+ checkpoint: String(checkpointId),
1190
+ messages: String(msgs.length),
1191
+ }),
1168
1192
  }
1169
1193
  }
1170
1194
 
@@ -1575,9 +1599,7 @@ const lintCmd: CommandHandler = async (ctx) => {
1575
1599
  '',
1576
1600
  truncated || '(no issues found)',
1577
1601
  '',
1578
- hasPackageJson
1579
- ? t('commands.lint.fix_hint')
1580
- : t('commands.lint.setup_hint'),
1602
+ hasPackageJson ? t('commands.lint.fix_hint') : t('commands.lint.setup_hint'),
1581
1603
  ].join('\n'),
1582
1604
  }
1583
1605
  } catch {
@@ -1618,7 +1640,9 @@ const filesCmd: CommandHandler = async (ctx) => {
1618
1640
  t('commands.files.cwd', { path: cwd }),
1619
1641
  '',
1620
1642
  ...items,
1621
- entries.length > 40 ? t('commands.files.more_files', { count: String(entries.length - 40) }) : '',
1643
+ entries.length > 40
1644
+ ? t('commands.files.more_files', { count: String(entries.length - 40) })
1645
+ : '',
1622
1646
  '',
1623
1647
  t('commands.files.hint'),
1624
1648
  ].join('\n'),
@@ -1643,7 +1667,12 @@ const statsCmd: CommandHandler = (ctx) => {
1643
1667
  content: [
1644
1668
  t('commands.stats.title'),
1645
1669
  '',
1646
- t('commands.stats.messages', { total: String(msgs.length), user: String(userMsgs), assistant: String(assistantMsgs), system: String(systemMsgs) }),
1670
+ t('commands.stats.messages', {
1671
+ total: String(msgs.length),
1672
+ user: String(userMsgs),
1673
+ assistant: String(assistantMsgs),
1674
+ system: String(systemMsgs),
1675
+ }),
1647
1676
  t('commands.stats.tokens', { tokens: tokens.toLocaleString() }),
1648
1677
  t('commands.stats.tools', { count: String(tools.size) }),
1649
1678
  t('commands.stats.provider', { provider: ctx.providerId }),
@@ -1738,7 +1767,12 @@ const hooksCmd: CommandHandler = async (ctx) => {
1738
1767
 
1739
1768
  try {
1740
1769
  const files = readdirSync(hooksDir).filter((f) => f.endsWith('.sh'))
1741
- const lines: string[] = [t('commands.hooks.title'), '', t('commands.hooks.location', { path: hooksDir }), '']
1770
+ const lines: string[] = [
1771
+ t('commands.hooks.title'),
1772
+ '',
1773
+ t('commands.hooks.location', { path: hooksDir }),
1774
+ '',
1775
+ ]
1742
1776
 
1743
1777
  for (const f of files) {
1744
1778
  try {
@@ -1801,7 +1835,11 @@ const exportCmd: CommandHandler = async (ctx) => {
1801
1835
 
1802
1836
  writeFileSync(filepath, lines.join('\n'), 'utf-8')
1803
1837
  return {
1804
- content: t('commands.export.confirmed', { path: filepath, count: String(msgs.length), lines: String(lines.length) }),
1838
+ content: t('commands.export.confirmed', {
1839
+ path: filepath,
1840
+ count: String(msgs.length),
1841
+ lines: String(lines.length),
1842
+ }),
1805
1843
  copyContent: filepath,
1806
1844
  }
1807
1845
  }
@@ -1886,7 +1924,9 @@ const resumeLastCmd: CommandHandler = async (ctx) => {
1886
1924
 
1887
1925
  const latest = SessionStore.getLatest()
1888
1926
  if (!latest) {
1889
- return { content: `${t('commands.resume.restored')}\n\n${t('commands.resume.no_sessions')}\n\n${t('commands.resume.empty_footer')}` }
1927
+ return {
1928
+ content: `${t('commands.resume.restored')}\n\n${t('commands.resume.no_sessions')}\n\n${t('commands.resume.empty_footer')}`,
1929
+ }
1890
1930
  }
1891
1931
 
1892
1932
  const session = SessionStore.load(latest.name)
@@ -1907,10 +1947,20 @@ const resumeLastCmd: CommandHandler = async (ctx) => {
1907
1947
  content: [
1908
1948
  t('commands.resume.restored'),
1909
1949
  '',
1910
- t('commands.resume.restored_content', { name: latest.name, total: String(session.messages.length), truncated: truncatedStr, provider: latest.provider, model: latest.model, date }),
1950
+ t('commands.resume.restored_content', {
1951
+ name: latest.name,
1952
+ total: String(session.messages.length),
1953
+ truncated: truncatedStr,
1954
+ provider: latest.provider,
1955
+ model: latest.model,
1956
+ date,
1957
+ }),
1911
1958
  '',
1912
1959
  truncated
1913
- ? t('commands.resume.restored_footer', { loaded: String(messages.length), total: String(session.messages.length) })
1960
+ ? t('commands.resume.restored_footer', {
1961
+ loaded: String(messages.length),
1962
+ total: String(session.messages.length),
1963
+ })
1914
1964
  : t('commands.resume.restored_full_footer', { loaded: String(messages.length) }),
1915
1965
  ].join('\n'),
1916
1966
  forwardedMessages: messages,
@@ -1954,13 +2004,17 @@ const resumeCmd: CommandHandler = async (ctx, args) => {
1954
2004
  content: `${t('commands.resume.found_title')}\n\n${t('commands.resume.found_content', { name: session.metadata.name, messages: String(session.metadata.messageCount), provider: session.metadata.provider, model: session.metadata.model, updated: session.metadata.updatedAt, target: targetName })}`,
1955
2005
  }
1956
2006
  }
1957
- return { content: `${t('commands.resume.not_found_title')}\n\n${t('commands.resume.not_found_content', { name: targetName })}` }
2007
+ return {
2008
+ content: `${t('commands.resume.not_found_title')}\n\n${t('commands.resume.not_found_content', { name: targetName })}`,
2009
+ }
1958
2010
  }
1959
2011
 
1960
2012
  const sessions = SessionStore.list()
1961
2013
 
1962
2014
  if (sessions.length === 0) {
1963
- return { content: `${t('commands.resume.restored')}\n\n${t('commands.resume.no_sessions')}\n\n${t('commands.resume.empty_footer')}` }
2015
+ return {
2016
+ content: `${t('commands.resume.restored')}\n\n${t('commands.resume.no_sessions')}\n\n${t('commands.resume.empty_footer')}`,
2017
+ }
1964
2018
  }
1965
2019
 
1966
2020
  const recent = sessions.slice(0, 10)
@@ -2029,7 +2083,10 @@ const memoryCmd: CommandHandler = async (ctx) => {
2029
2083
  t('commands.memories.title'),
2030
2084
  '',
2031
2085
  t('commands.memories.location', { path: memoryDir }),
2032
- t('commands.memories.total', { count: String(memories.length), plural: memories.length === 1 ? 'y' : 'ies' }),
2086
+ t('commands.memories.total', {
2087
+ count: String(memories.length),
2088
+ plural: memories.length === 1 ? 'y' : 'ies',
2089
+ }),
2033
2090
  '',
2034
2091
  ...memories.map(
2035
2092
  (m, i) =>
@@ -2095,7 +2152,9 @@ const upgradeCmd: CommandHandler = async (ctx) => {
2095
2152
  lines.push(t('commands.upgrade.old_version_warning'))
2096
2153
  } else {
2097
2154
  lines.push('')
2098
- lines.push(t('commands.upgrade.update_failed', { command: NPM_UPDATE_COMMAND, path: backupPath || '' }))
2155
+ lines.push(
2156
+ t('commands.upgrade.update_failed', { command: NPM_UPDATE_COMMAND, path: backupPath || '' }),
2157
+ )
2099
2158
  }
2100
2159
 
2101
2160
  return { content: lines.join('\n') }
@@ -2862,16 +2921,15 @@ const forkCmd: CommandHandler = async (ctx, args) => {
2862
2921
  }
2863
2922
 
2864
2923
  async function openBrowser(url: string): Promise<void> {
2865
- const cmd =
2924
+ const { spawn } = await import('node:child_process')
2925
+ // Use spawn with array args — no shell, no command injection
2926
+ const [cmd, ...args] =
2866
2927
  process.platform === 'darwin'
2867
- ? `open "${url}"`
2928
+ ? ['open', url]
2868
2929
  : process.platform === 'win32'
2869
- ? `start "" "${url}"`
2870
- : `xdg-open "${url}"`
2871
- const { exec } = await import('node:child_process')
2872
- exec(cmd, () => {
2873
- /* fire-and-forget */
2874
- })
2930
+ ? ['cmd', '/c', 'start', '', url]
2931
+ : ['xdg-open', url]
2932
+ spawn(cmd!, args, { detached: true, stdio: 'ignore' }).unref()
2875
2933
  }
2876
2934
 
2877
2935
  const artifactOpenCmd: CommandHandler = async (ctx, args) => {
package/src/ui/picker.tsx CHANGED
@@ -152,7 +152,9 @@ export function ModelPicker({
152
152
  {isCurrent ? '✓' : ' '}
153
153
  {p.name.padEnd(16)}
154
154
  </Text>
155
- <Text dimColor>{isUpcoming ? t('ui.picker.coming_soon') : `${activeModels(p).length}m`}</Text>
155
+ <Text dimColor>
156
+ {isUpcoming ? t('ui.picker.coming_soon') : `${activeModels(p).length}m`}
157
+ </Text>
156
158
  </Box>
157
159
  )
158
160
  })}