@miphamai/cli 0.28.0 → 0.30.0

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@miphamai/cli",
3
- "version": "0.28.0",
3
+ "version": "0.30.0",
4
4
  "description": "Mipham Code — Multi-model open-core intelligent coding terminal by MiphamAI",
5
5
  "keywords": [
6
6
  "ai",
@@ -24,58 +24,58 @@ Mipham Code 审计 Mipham Code — 这是 CRSI 递归自我改进的第一个闭
24
24
 
25
25
  ### 1. 代码质量
26
26
 
27
- | 检查项 | 方法 |
28
- |--------|------|
29
- | Dead code | Grep 搜索未被引用的 export、未使用的 import |
30
- | 不一致模式 | 对比同一目录下多个文件的代码风格/模式差异 |
31
- | 类型安全 | 搜索 `as any`、`@ts-ignore`、`unknown` 未收窄 |
32
- | 错误处理 | 搜索裸 `catch`、无 `try/catch` 的 async 调用 |
33
- | Deep nesting | 搜索嵌套超过 4 层的 if/for/switch |
27
+ | 检查项 | 方法 |
28
+ | ------------ | --------------------------------------------- |
29
+ | Dead code | Grep 搜索未被引用的 export、未使用的 import |
30
+ | 不一致模式 | 对比同一目录下多个文件的代码风格/模式差异 |
31
+ | 类型安全 | 搜索 `as any`、`@ts-ignore`、`unknown` 未收窄 |
32
+ | 错误处理 | 搜索裸 `catch`、无 `try/catch` 的 async 调用 |
33
+ | Deep nesting | 搜索嵌套超过 4 层的 if/for/switch |
34
34
 
35
35
  ### 2. 架构完整性
36
36
 
37
- | 检查项 | 方法 |
38
- |--------|------|
39
- | 循环依赖 | 分析 import 图,检测 A→B→A |
40
- | 接口契约 | 对比 `shared/types.ts` 中的类型定义与实际使用 |
41
- | 模块边界 | 检查是否有跨层级直接访问(ui/ 直接 import core/ 内部实现) |
42
- | God objects | 搜索超过 500 行的单个类/函数 |
37
+ | 检查项 | 方法 |
38
+ | ----------- | ---------------------------------------------------------- |
39
+ | 循环依赖 | 分析 import 图,检测 A→B→A |
40
+ | 接口契约 | 对比 `shared/types.ts` 中的类型定义与实际使用 |
41
+ | 模块边界 | 检查是否有跨层级直接访问(ui/ 直接 import core/ 内部实现) |
42
+ | God objects | 搜索超过 500 行的单个类/函数 |
43
43
 
44
44
  ### 3. 性能
45
45
 
46
- | 检查项 | 方法 |
47
- |--------|------|
48
- | 同步阻塞 | 搜索 `readFileSync`、`execSync` 在主线程中 |
46
+ | 检查项 | 方法 |
47
+ | ------------ | ----------------------------------------------- |
48
+ | 同步阻塞 | 搜索 `readFileSync`、`execSync` 在主线程中 |
49
49
  | 内存泄漏风险 | 搜索未清理的 setInterval、EventEmitter listener |
50
- | 渲染性能 | 检查 React memo/callback 使用是否完整 |
51
- | N+1 模式 | 搜索在循环内的 I/O 操作 |
50
+ | 渲染性能 | 检查 React memo/callback 使用是否完整 |
51
+ | N+1 模式 | 搜索在循环内的 I/O 操作 |
52
52
 
53
53
  ### 4. 安全
54
54
 
55
- | 检查项 | 方法 |
56
- |--------|------|
57
- | 硬编码凭据 | 搜索 API key、token、password 字符串 |
58
- | 路径遍历 | 搜索使用用户输入的 `join`/`resolve` 路径 |
59
- | 命令注入 | 搜索字符串拼接的 shell 命令 |
60
- | 许可合规 | 检查 package.json 中的 copyleft 依赖 |
55
+ | 检查项 | 方法 |
56
+ | ---------- | ---------------------------------------- |
57
+ | 硬编码凭据 | 搜索 API key、token、password 字符串 |
58
+ | 路径遍历 | 搜索使用用户输入的 `join`/`resolve` 路径 |
59
+ | 命令注入 | 搜索字符串拼接的 shell 命令 |
60
+ | 许可合规 | 检查 package.json 中的 copyleft 依赖 |
61
61
 
62
62
  ### 5. 测试覆盖
63
63
 
64
- | 检查项 | 方法 |
65
- |--------|------|
66
- | 未测试模块 | Glob 所有 `src/**/*.ts`,对比 `test/` 目录 |
67
- | 关键路径覆盖 | 识别 engine、permission、tools 层,检查测试 |
64
+ | 检查项 | 方法 |
65
+ | --------------- | ------------------------------------------------- |
66
+ | 未测试模块 | Glob 所有 `src/**/*.ts`,对比 `test/` 目录 |
67
+ | 关键路径覆盖 | 识别 engine、permission、tools 层,检查测试 |
68
68
  | Flaky test 风险 | 搜索 `setTimeout`、`Math.random`、Date 依赖的测试 |
69
- | 边界测试缺失 | 检查主要函数的 null/undefined/empty 参数测试 |
69
+ | 边界测试缺失 | 检查主要函数的 null/undefined/empty 参数测试 |
70
70
 
71
71
  ### 6. CRSI 集成健康
72
72
 
73
- | 检查项 | 方法 |
74
- |--------|------|
75
- | Rule 引擎状态 | 检查活跃规则数、禁用规则数、builtin vs auto-generated |
76
- | 效果追踪 | 从 EffectivenessTracker 读取规则成功率 |
77
- | 模式分析器 | 检查累积的 Agent 失败模式 |
78
- | AutoMemory 状态 | 检查复盘文件数量、CRSI 洞察统计 |
73
+ | 检查项 | 方法 |
74
+ | --------------- | ----------------------------------------------------- |
75
+ | Rule 引擎状态 | 检查活跃规则数、禁用规则数、builtin vs auto-generated |
76
+ | 效果追踪 | 从 EffectivenessTracker 读取规则成功率 |
77
+ | 模式分析器 | 检查累积的 Agent 失败模式 |
78
+ | AutoMemory 状态 | 检查复盘文件数量、CRSI 洞察统计 |
79
79
 
80
80
  ## 执行流程
81
81
 
@@ -125,14 +125,14 @@ Mipham Code 审计 Mipham Code — 这是 CRSI 递归自我改进的第一个闭
125
125
 
126
126
  ## 摘要
127
127
 
128
- | 维度 | 评分 | 发现数 | 严重 |
129
- |------|------|--------|------|
130
- | 代码质量 | 7/10 | 12 | 2 |
131
- | 架构完整性 | 8/10 | 3 | 0 |
132
- | 性能 | 7/10 | 5 | 1 |
133
- | 安全 | 8/10 | 2 | 0 |
134
- | 测试覆盖 | 7/10 | 8 | 1 |
135
- | CRSI 健康 | 9/10 | 0 | 0 |
128
+ | 维度 | 评分 | 发现数 | 严重 |
129
+ | ---------- | ---- | ------ | ---- |
130
+ | 代码质量 | 7/10 | 12 | 2 |
131
+ | 架构完整性 | 8/10 | 3 | 0 |
132
+ | 性能 | 7/10 | 5 | 1 |
133
+ | 安全 | 8/10 | 2 | 0 |
134
+ | 测试覆盖 | 7/10 | 8 | 1 |
135
+ | CRSI 健康 | 9/10 | 0 | 0 |
136
136
 
137
137
  ## 🔴 严重 (需要立即处理)
138
138
 
@@ -148,9 +148,9 @@ Mipham Code 审计 Mipham Code — 这是 CRSI 递归自我改进的第一个闭
148
148
 
149
149
  ## CRSI 规则更新
150
150
 
151
- | 规则 ID | 类型 | 状态 | 上次评估 |
152
- |---------|------|------|---------|
153
- | ... | ... | active/degraded/disabled | ... |
151
+ | 规则 ID | 类型 | 状态 | 上次评估 |
152
+ | ------- | ---- | ------------------------ | -------- |
153
+ | ... | ... | active/degraded/disabled | ... |
154
154
  ```
155
155
 
156
156
  ## 安全约束
@@ -419,10 +419,7 @@ export class AutoMemoryEngine {
419
419
  return decisions.slice(0, 5)
420
420
  }
421
421
 
422
- private extractActionItems(
423
- assistantContent: string,
424
- toolCalls: ToolCallRecord[],
425
- ): string[] {
422
+ private extractActionItems(assistantContent: string, toolCalls: ToolCallRecord[]): string[] {
426
423
  const items: string[] = []
427
424
 
428
425
  // Check for TODO markers in assistant content
@@ -594,7 +591,13 @@ export class AutoMemoryEngine {
594
591
  `| 成功率 | ${totalSuccesses + totalFailures > 0 ? Math.round((totalSuccesses / (totalSuccesses + totalFailures)) * 100) : 100}% |`,
595
592
  '',
596
593
  ...(criticalInsights.length > 0
597
- ? ['## ⚠️ 关键洞察', '', ...criticalInsights.map((i) => `- **${i.category}**: ${i.description}\n → ${i.suggestion}`)]
594
+ ? [
595
+ '## ⚠️ 关键洞察',
596
+ '',
597
+ ...criticalInsights.map(
598
+ (i) => `- **${i.category}**: ${i.description}\n → ${i.suggestion}`,
599
+ ),
600
+ ]
598
601
  : []),
599
602
  '',
600
603
  '---',
@@ -52,7 +52,8 @@ export interface CrsiModificationResult {
52
52
  /** Error message if something failed */
53
53
  error?: string
54
54
  /** Current phase of the sandbox pipeline */
55
- phase: 'pending' | 'applied' | 'testing' | 'passed' | 'failed' | 'approved' | 'merged' | 'rolled-back'
55
+ phase:
56
+ 'pending' | 'applied' | 'testing' | 'passed' | 'failed' | 'approved' | 'merged' | 'rolled-back'
56
57
  }
57
58
 
58
59
  export interface CrsiTestResult {
@@ -154,7 +155,12 @@ export class CrsiSandbox {
154
155
  */
155
156
  applyModification(mod: CrsiModification): CrsiModificationResult {
156
157
  if (!this.worktreePath) {
157
- return { modification: mod, applied: false, phase: 'pending', error: 'No worktree created. Call createWorktree() first.' }
158
+ return {
159
+ modification: mod,
160
+ applied: false,
161
+ phase: 'pending',
162
+ error: 'No worktree created. Call createWorktree() first.',
163
+ }
158
164
  }
159
165
 
160
166
  this.sessionReport.summary.total++
@@ -180,7 +186,8 @@ export class CrsiSandbox {
180
186
  try {
181
187
  const currentContent = readFileSync(targetPath, 'utf-8')
182
188
  if (currentContent !== mod.originalContent && mod.originalContent) {
183
- result.error = 'Original content mismatch — file may have been modified since the audit. Aborting.'
189
+ result.error =
190
+ 'Original content mismatch — file may have been modified since the audit. Aborting.'
184
191
  result.phase = 'failed'
185
192
  this.sessionReport.modifications.push(result)
186
193
  return result
@@ -313,9 +320,7 @@ export class CrsiSandbox {
313
320
  }
314
321
 
315
322
  // Check that all modifications passed tests
316
- const failedCount = this.sessionReport.modifications.filter(
317
- (m) => m.phase === 'failed',
318
- ).length
323
+ const failedCount = this.sessionReport.modifications.filter((m) => m.phase === 'failed').length
319
324
  if (failedCount > 0) {
320
325
  return {
321
326
  success: false,
package/src/index.tsx CHANGED
@@ -24,7 +24,7 @@ import { ContextManager } from './core/context'
24
24
  import { QueryEngine } from './core/engine'
25
25
  import { ExperienceRuleEngine } from './core/rule-engine.js'
26
26
  import { SessionStore } from './core/session-store'
27
- import type { PermissionLevel } from './shared/types'
27
+ import type { PermissionLevel, MiphamConfig } from './shared/types'
28
28
  import { SkillsLoader } from './skills/loader'
29
29
  import { PluginManager } from './plugin/plugin-manager'
30
30
  import { loadPlugins } from './plugin/plugin-loader'
@@ -42,6 +42,7 @@ import { AgentViewDashboard } from './agent-view/dashboard'
42
42
  import { createT } from './i18n-core/t'
43
43
  import { detectLocale } from './i18n-core/detect'
44
44
  import { I18nProvider } from './i18n-context'
45
+ import { ConfigWizard } from './ui/config-wizard'
45
46
  import enUS from './i18n-core/locales/en-US.json' with { type: 'json' }
46
47
  import zhCN from './i18n-core/locales/zh-CN.json' with { type: 'json' }
47
48
  import type { TranslationMap } from './i18n-core/types'
@@ -111,6 +112,70 @@ async function checkWorkspaceTrust(): Promise<void> {
111
112
  }
112
113
  }
113
114
 
115
+ // ── SetupGate: first-run wizard → App bridge ──
116
+
117
+ interface SetupGateProps {
118
+ needsSetup: boolean
119
+ engine: QueryEngine
120
+ config: MiphamConfig
121
+ defaultProvider: string
122
+ defaultModel: string
123
+ lang?: string
124
+ skillsLoader?: SkillsLoader
125
+ pluginManager?: PluginManager
126
+ version?: string
127
+ sessionId: string
128
+ agentViewManager?: AgentViewManager
129
+ locale: string
130
+ t: (key: string) => string
131
+ }
132
+
133
+ function SetupGate(props: SetupGateProps) {
134
+ const [showWizard, setShowWizard] = React.useState(props.needsSetup)
135
+ const [wizardProvider, setWizardProvider] = React.useState<string | null>(null)
136
+ const [wizardModel, setWizardModel] = React.useState<string | null>(null)
137
+
138
+ // When wizard completes: write config, switch provider, proceed to App
139
+ const handleWizardComplete = React.useCallback(
140
+ (wizardConfig: { providerId: string; modelId: string; apiKey: string }) => {
141
+ // Switch the engine to the newly configured provider
142
+ try {
143
+ props.engine.switchProvider(wizardConfig.providerId, wizardConfig.modelId)
144
+ } catch {
145
+ // If switch fails (e.g. provider not yet in registry), just use as-is
146
+ }
147
+ setWizardProvider(wizardConfig.providerId)
148
+ setWizardModel(wizardConfig.modelId)
149
+ setShowWizard(false)
150
+ },
151
+ [props.engine],
152
+ )
153
+
154
+ const handleWizardSkip = React.useCallback(() => {
155
+ setShowWizard(false)
156
+ }, [])
157
+
158
+ if (showWizard) {
159
+ return React.createElement(ConfigWizard, {
160
+ onComplete: handleWizardComplete,
161
+ onSkip: handleWizardSkip,
162
+ })
163
+ }
164
+
165
+ return React.createElement(App, {
166
+ engine: props.engine,
167
+ config: props.config,
168
+ initialProvider: wizardProvider || props.defaultProvider,
169
+ initialModel: wizardModel || props.defaultModel,
170
+ lang: props.lang,
171
+ skillsLoader: props.skillsLoader,
172
+ pluginManager: props.pluginManager,
173
+ version: props.version,
174
+ sessionId: props.sessionId,
175
+ agentViewManager: props.agentViewManager,
176
+ })
177
+ }
178
+
114
179
  export async function runApp(options: RunOptions): Promise<void> {
115
180
  // Metrics: count CLI invocation
116
181
  getMetrics().cliInvocations.inc()
@@ -481,21 +546,29 @@ export async function runApp(options: RunOptions): Promise<void> {
481
546
  }
482
547
  })
483
548
 
549
+ // ── First-run detection: show interactive ConfigWizard if no config exists ──
550
+ const hasUserConfig = existsSync(join(homedir(), '.mipham', 'config.yml'))
551
+ const hasProjectConfig = existsSync(join(process.cwd(), '.mipham', 'config.yml'))
552
+ const needsSetup = !hasUserConfig && !hasProjectConfig
553
+
484
554
  const { waitUntilExit } = render(
485
555
  React.createElement(I18nProvider, {
486
556
  locale,
487
557
  t,
488
- children: React.createElement(App, {
558
+ children: React.createElement(SetupGate, {
559
+ needsSetup,
489
560
  engine,
490
561
  config,
491
- initialProvider: defaultProvider,
492
- initialModel: defaultModel,
562
+ defaultProvider,
563
+ defaultModel,
493
564
  lang: options.lang,
494
565
  skillsLoader,
495
566
  pluginManager,
496
567
  version: options.version,
497
568
  sessionId: sessionName,
498
569
  agentViewManager,
570
+ locale,
571
+ t,
499
572
  }),
500
573
  }),
501
574
  )
@@ -35,6 +35,33 @@ export const DEFAULT_PROVIDERS: ProviderConfig[] = [
35
35
  vision: true,
36
36
  status: 'active',
37
37
  },
38
+ {
39
+ id: 'claude-fable-5',
40
+ name: 'Claude Fable 5',
41
+ providerId: 'anthropic',
42
+ contextWindow: 1_000_000,
43
+ maxOutput: 128_000,
44
+ vision: true,
45
+ status: 'active',
46
+ },
47
+ {
48
+ id: 'claude-sonnet-5',
49
+ name: 'Claude Sonnet 5',
50
+ providerId: 'anthropic',
51
+ contextWindow: 1_000_000,
52
+ maxOutput: 128_000,
53
+ vision: true,
54
+ status: 'active',
55
+ },
56
+ {
57
+ id: 'claude-opus-5',
58
+ name: 'Claude Opus 5',
59
+ providerId: 'anthropic',
60
+ contextWindow: 1_000_000,
61
+ maxOutput: 128_000,
62
+ vision: true,
63
+ status: 'active',
64
+ },
38
65
  ],
39
66
  },
40
67
  {
package/src/ui/app.tsx CHANGED
@@ -44,6 +44,8 @@ export interface ToolMeta {
44
44
  input: string
45
45
  output?: string
46
46
  collapsed: boolean
47
+ /** Original tool name before Claude Code display renaming (e.g. Write→Update). */
48
+ originalName?: string
47
49
  }
48
50
 
49
51
  export interface ChatMessage {
@@ -87,7 +89,8 @@ const PERMISSION_COLORS: Record<PermissionMode, string> = {
87
89
  bypassPermissions: 'red',
88
90
  }
89
91
 
90
- /** Format a tool's input parameters into a compact one-line detail string. */
92
+ /** Format a tool's input parameters into a compact one-line detail string.
93
+ * Tool display names follow Claude Code convention: Write/Edit → Update. */
91
94
  function formatToolDetail(name: string, input: Record<string, unknown>): string {
92
95
  switch (name) {
93
96
  case 'Bash':
@@ -95,9 +98,9 @@ function formatToolDetail(name: string, input: Record<string, unknown>): string
95
98
  case 'Read':
96
99
  return (input.file_path as string) || ''
97
100
  case 'Write':
98
- return (input.file_path as string) || ''
101
+ return (input.file_path as string) || '' // displayed as "Update" in chat
99
102
  case 'Edit':
100
- return (input.file_path as string) || ''
103
+ return `${(input.file_path as string) || ''}: ${((input.old_string as string) || '').slice(0, 60)}` // displayed as "Update" in chat
101
104
  case 'Grep':
102
105
  return (input.pattern as string) || ''
103
106
  case 'Glob':
@@ -115,6 +118,12 @@ function formatToolDetail(name: string, input: Record<string, unknown>): string
115
118
  }
116
119
  }
117
120
 
121
+ /** Claude Code display name: maps Write/Edit → Update for parity. */
122
+ function toolDisplayName(name: string): string {
123
+ if (name === 'Write' || name === 'Edit') return 'Update'
124
+ return name
125
+ }
126
+
118
127
  export function App({
119
128
  engine,
120
129
  config,
@@ -462,7 +471,12 @@ export function App({
462
471
  {
463
472
  role: 'system' as const,
464
473
  content: detail,
465
- toolMeta: { name: toolName, input: detail, collapsed: true },
474
+ toolMeta: {
475
+ name: toolDisplayName(toolName),
476
+ input: detail,
477
+ collapsed: true,
478
+ originalName: toolName,
479
+ },
466
480
  },
467
481
  ])
468
482
  // Flush stream buffer before showing tool card
@@ -475,18 +489,35 @@ export function App({
475
489
  setAgentProgress(null)
476
490
  // Sync background agents (e.g. Agent tool may have spawned them)
477
491
  syncBgAgents()
478
- // Only show tool results with meaningful content.
479
- // Skip empty results, "(no matches)", and other noise.
480
492
  const output = chunk.content ? String(chunk.content).trim() : ''
481
493
  if (output && output !== '(no matches)' && output.length > 20) {
482
- const firstLine = output.split('\n')[0]!.slice(0, 200)
483
- const preview = firstLine.length < output.length ? `${firstLine}...` : firstLine
494
+ // Generate diff-style summary for file operations (Claude Code parity)
495
+ let preview: string
496
+ const isFileOp = chunk.toolUse?.name === 'Write' || chunk.toolUse?.name === 'Edit'
497
+ if (isFileOp && chunk.toolUse?.input?.file_path) {
498
+ const filePath = String(chunk.toolUse.input.file_path)
499
+ const newContent = String(
500
+ chunk.toolUse.input.content || chunk.toolUse.input.new_string || '',
501
+ )
502
+ // Count lines added/removed for Write operations
503
+ const lineCount = newContent.split('\n').length
504
+ preview = `Updated ${filePath} · ${lineCount} lines`
505
+ } else {
506
+ const firstLine = output.split('\n')[0]!.slice(0, 200)
507
+ preview = firstLine.length < output.length ? `${firstLine}...` : firstLine
508
+ }
484
509
  setMessages((prev) => [
485
510
  ...prev,
486
511
  {
487
512
  role: 'system' as const,
488
- content: ` ⎿ ${preview}`,
489
- toolMeta: { name: '', input: '', output: preview, collapsed: true },
513
+ content: preview,
514
+ toolMeta: {
515
+ name: '',
516
+ input: '',
517
+ output: preview,
518
+ collapsed: true,
519
+ originalName: chunk.toolUse?.name,
520
+ },
490
521
  },
491
522
  ])
492
523
  }
package/src/ui/chat.tsx CHANGED
@@ -28,6 +28,7 @@ function toolColor(name: string): string {
28
28
  return 'white'
29
29
  case 'Write':
30
30
  case 'Edit':
31
+ case 'Update':
31
32
  return 'red'
32
33
  case 'Agent':
33
34
  case 'Task':
@@ -59,12 +60,19 @@ const MessageRow = React.memo(
59
60
  >
60
61
  {msg.toolMeta ? (
61
62
  <Box flexDirection="column">
62
- <Text color={toolColor(msg.toolMeta.name)}>
63
- {msg.toolMeta.collapsed ? '⏺' : '⏺ ▼'} {msg.toolMeta.name}
64
- {msg.toolMeta.input ? `(${msg.toolMeta.input.slice(0, 120)})` : ''}
65
- </Text>
63
+ {/* Tool call line: ⏺ ToolName(detail) — Claude Code parity */}
64
+ {msg.toolMeta.name ? (
65
+ <Text color={toolColor(msg.toolMeta.name)}>
66
+ {msg.toolMeta.collapsed ? '⏺' : '⏺ ▼'} {msg.toolMeta.name}
67
+ {msg.toolMeta.input ? ` (${msg.toolMeta.input.slice(0, 120)})` : ''}
68
+ </Text>
69
+ ) : null}
70
+ {/* Tool result line: ⎿ summary text */}
66
71
  {msg.toolMeta.output ? (
67
- <Text dimColor> ⎿ {msg.toolMeta.output.slice(0, 300)}</Text>
72
+ <Text dimColor>
73
+ {' ⎿ '}
74
+ {msg.toolMeta.output.slice(0, 300)}
75
+ </Text>
68
76
  ) : msg.content && !msg.toolMeta.collapsed ? (
69
77
  <Text dimColor>{msg.content}</Text>
70
78
  ) : null}
@@ -0,0 +1,519 @@
1
+ /**
2
+ * Interactive first-run Config Wizard
3
+ *
4
+ * Replaces manual config.yml editing for non-technical users.
5
+ * Flow: Welcome → Local/Cloud → Provider → Model → API Key → Done
6
+ *
7
+ * Local path: Ollama setup (endpoint + model pull)
8
+ * Cloud path: pick provider → pick model → paste API key → auto-configure
9
+ */
10
+
11
+ import React, { useState, useCallback } from 'react'
12
+ import { Box, Text, useInput } from 'ink'
13
+ import TextInput from 'ink-text-input'
14
+ import { DEFAULT_PROVIDERS } from '../shared/constants'
15
+ import type { ProviderConfig, ModelInfo } from '../shared/types'
16
+ import { mkdirSync, writeFileSync, existsSync } from 'node:fs'
17
+ import { join } from 'node:path'
18
+ import { homedir } from 'node:os'
19
+ import { execSync } from 'node:child_process'
20
+
21
+ // ── Types ──
22
+
23
+ interface WizardState {
24
+ step:
25
+ | 'welcome'
26
+ | 'mode'
27
+ | 'cloud-provider'
28
+ | 'cloud-model'
29
+ | 'cloud-key'
30
+ | 'local-ollama'
31
+ | 'confirm'
32
+ | 'done'
33
+ mode: 'local' | 'cloud' | null
34
+ providerId: string | null
35
+ modelId: string | null
36
+ apiKey: string
37
+ ollamaEndpoint: string
38
+ ollamaModel: string
39
+ error: string | null
40
+ }
41
+
42
+ interface Props {
43
+ onComplete: (config: { providerId: string; modelId: string; apiKey: string }) => void
44
+ onSkip: () => void
45
+ }
46
+
47
+ // ── Helpers ──
48
+
49
+ /** Filter to cloud-only providers (exclude ollama/mipham local-only). */
50
+ const CLOUD_PROVIDERS = DEFAULT_PROVIDERS.filter((p) => p.id !== 'ollama' && p.id !== 'mipham')
51
+
52
+ /** Get active models for a provider. */
53
+ function getModels(providerId: string): ModelInfo[] {
54
+ const provider = DEFAULT_PROVIDERS.find((p) => p.id === providerId)
55
+ return provider?.models.filter((m) => m.status === 'active') || []
56
+ }
57
+
58
+ /** Write config.yml to ~/.mipham/ */
59
+ function writeConfig(providerId: string, modelId: string, apiKey: string): void {
60
+ const configDir = join(homedir(), '.mipham')
61
+ mkdirSync(configDir, { recursive: true })
62
+
63
+ const provider = DEFAULT_PROVIDERS.find((p) => p.id === providerId)
64
+ const configLines = [
65
+ `# Mipham Code Configuration`,
66
+ `# Generated by Config Wizard`,
67
+ `# ${new Date().toISOString()}`,
68
+ ``,
69
+ `version: 1`,
70
+ `defaultProvider: ${providerId}`,
71
+ `defaultModel: ${modelId}`,
72
+ `permission: default`,
73
+ ``,
74
+ `providers:`,
75
+ ` - id: ${providerId}`,
76
+ ` name: ${provider?.name || providerId}`,
77
+ ` protocol: ${provider?.protocol || 'openai-compatible'}`,
78
+ ...(provider?.baseUrl ? [` baseUrl: ${provider.baseUrl}`] : []),
79
+ ` apiKey: ${apiKey}`,
80
+ ` models:`,
81
+ ]
82
+
83
+ const models = getModels(providerId)
84
+ for (const m of models) {
85
+ configLines.push(` - id: ${m.id}`)
86
+ }
87
+
88
+ writeFileSync(join(configDir, 'config.yml'), configLines.join('\n'), 'utf-8')
89
+ }
90
+
91
+ /** Check if Ollama is installed and running. */
92
+ function checkOllama(): { installed: boolean; running: boolean; models: string[] } {
93
+ try {
94
+ execSync('ollama --version', { timeout: 5000, stdio: 'ignore' })
95
+ const models = execSync('ollama list', { timeout: 5000, encoding: 'utf-8' })
96
+ .split('\n')
97
+ .slice(1)
98
+ .filter(Boolean)
99
+ .map((l) => l.split(/\s+/)[0]!)
100
+ return { installed: true, running: true, models }
101
+ } catch {
102
+ try {
103
+ execSync('ollama --version', { timeout: 5000, stdio: 'ignore' })
104
+ return { installed: true, running: false, models: [] }
105
+ } catch {
106
+ return { installed: false, running: false, models: [] }
107
+ }
108
+ }
109
+ }
110
+
111
+ // ── Component ──
112
+
113
+ export function ConfigWizard({ onComplete, onSkip }: Props) {
114
+ const [state, setState] = useState<WizardState>({
115
+ step: 'welcome',
116
+ mode: null,
117
+ providerId: null,
118
+ modelId: null,
119
+ apiKey: '',
120
+ ollamaEndpoint: 'http://localhost:11434',
121
+ ollamaModel: '',
122
+ error: null,
123
+ })
124
+
125
+ const [selectedIdx, setSelectedIdx] = useState(0)
126
+ const [inputValue, setInputValue] = useState('')
127
+ const [ollamaStatus, setOllamaStatus] = useState<ReturnType<typeof checkOllama> | null>(null)
128
+
129
+ // ── Navigation helpers ──
130
+
131
+ const goTo = useCallback((step: WizardState['step']) => {
132
+ setSelectedIdx(0)
133
+ setInputValue('')
134
+ setState((s) => ({ ...s, step, error: null }))
135
+ }, [])
136
+
137
+ const setMode = useCallback(
138
+ (mode: 'local' | 'cloud') => {
139
+ setState((s) => ({ ...s, mode }))
140
+ if (mode === 'cloud') {
141
+ goTo('cloud-provider')
142
+ } else {
143
+ const status = checkOllama()
144
+ setOllamaStatus(status)
145
+ goTo('local-ollama')
146
+ }
147
+ },
148
+ [goTo],
149
+ )
150
+
151
+ const selectProvider = useCallback(
152
+ (providerId: string) => {
153
+ setState((s) => ({ ...s, providerId }))
154
+ goTo('cloud-model')
155
+ },
156
+ [goTo],
157
+ )
158
+
159
+ const selectModel = useCallback(
160
+ (modelId: string) => {
161
+ setState((s) => ({ ...s, modelId }))
162
+ goTo('cloud-key')
163
+ },
164
+ [goTo],
165
+ )
166
+
167
+ const confirmApiKey = useCallback(() => {
168
+ const key = inputValue.trim()
169
+ if (!key) {
170
+ setState((s) => ({ ...s, error: '请输入 API Key' }))
171
+ return
172
+ }
173
+ setState((s) => ({ ...s, apiKey: key, error: null }))
174
+ goTo('confirm')
175
+ }, [inputValue, goTo])
176
+
177
+ const finishLocal = useCallback(() => {
178
+ const endpoint = inputValue.trim() || 'http://localhost:11434'
179
+ const model = state.ollamaModel || 'llama3.2'
180
+ try {
181
+ writeConfig('ollama', model, 'ollama-local')
182
+ onComplete({ providerId: 'ollama', modelId: model, apiKey: 'ollama-local' })
183
+ } catch (err) {
184
+ setState((s) => ({ ...s, error: `配置写入失败: ${String(err)}` }))
185
+ }
186
+ }, [inputValue, state.ollamaModel, onComplete])
187
+
188
+ const finishCloud = useCallback(() => {
189
+ if (!state.providerId || !state.modelId || !state.apiKey) return
190
+ try {
191
+ writeConfig(state.providerId, state.modelId, state.apiKey)
192
+ onComplete({ providerId: state.providerId, modelId: state.modelId, apiKey: state.apiKey })
193
+ } catch (err) {
194
+ setState((s) => ({ ...s, error: `配置写入失败: ${String(err)}` }))
195
+ }
196
+ }, [state, onComplete])
197
+
198
+ // ── Keyboard ──
199
+
200
+ useInput((_input, key) => {
201
+ if (key.escape) {
202
+ onSkip()
203
+ return
204
+ }
205
+
206
+ if (state.step === 'welcome') {
207
+ if (key.return) goTo('mode')
208
+ return
209
+ }
210
+
211
+ if (state.step === 'mode') {
212
+ if (key.upArrow || key.downArrow) {
213
+ setSelectedIdx((i) => (i === 0 ? 1 : 0))
214
+ }
215
+ if (key.return) {
216
+ setMode(selectedIdx === 0 ? 'cloud' : 'local')
217
+ }
218
+ return
219
+ }
220
+
221
+ // Cloud provider / model selection (list navigation)
222
+ if (state.step === 'cloud-provider' || state.step === 'cloud-model') {
223
+ const items =
224
+ state.step === 'cloud-provider'
225
+ ? CLOUD_PROVIDERS
226
+ : state.providerId
227
+ ? getModels(state.providerId)
228
+ : []
229
+
230
+ if (items.length === 0) return
231
+
232
+ if (key.upArrow) {
233
+ setSelectedIdx((i) => (i > 0 ? i - 1 : items.length - 1))
234
+ }
235
+ if (key.downArrow) {
236
+ setSelectedIdx((i) => (i < items.length - 1 ? i + 1 : 0))
237
+ }
238
+ if (key.return) {
239
+ const item = items[selectedIdx]
240
+ if (item) {
241
+ if (state.step === 'cloud-provider') {
242
+ selectProvider((item as ProviderConfig).id)
243
+ } else {
244
+ selectModel((item as ModelInfo).id)
245
+ }
246
+ }
247
+ }
248
+ return
249
+ }
250
+
251
+ // API key input: Enter submits
252
+ if (state.step === 'cloud-key') {
253
+ if (key.return) {
254
+ confirmApiKey()
255
+ }
256
+ return
257
+ }
258
+
259
+ // Local Ollama input
260
+ if (state.step === 'local-ollama') {
261
+ if (key.return) {
262
+ finishLocal()
263
+ }
264
+ return
265
+ }
266
+
267
+ // Confirm
268
+ if (state.step === 'confirm') {
269
+ if (key.return) {
270
+ finishCloud()
271
+ }
272
+ if (key.backspace) {
273
+ goTo('cloud-key')
274
+ }
275
+ return
276
+ }
277
+ })
278
+
279
+ // ── Render ──
280
+
281
+ const selectedColor = 'cyan'
282
+ const dimColor = 'gray'
283
+
284
+ return (
285
+ <Box flexDirection="column" padding={1}>
286
+ {/* Header */}
287
+ <Box marginBottom={1}>
288
+ <Text color="#FFD700" bold>
289
+ Mipham Code
290
+ </Text>
291
+ <Text dimColor> — 配置向导</Text>
292
+ </Box>
293
+
294
+ <Box marginBottom={1}>
295
+ <Text dimColor>━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━</Text>
296
+ </Box>
297
+
298
+ {/* ── Welcome ── */}
299
+ {state.step === 'welcome' && (
300
+ <Box flexDirection="column">
301
+ <Box marginBottom={1}>
302
+ <Text bold color="cyan">
303
+ 欢迎使用 Mipham Code!
304
+ </Text>
305
+ </Box>
306
+ <Box marginBottom={1}>
307
+ <Text>在开始之前,需要先配置 AI 模型连接。</Text>
308
+ </Box>
309
+ <Box marginBottom={1}>
310
+ <Text>此向导只需 1-2 分钟,需要您准备好 API Key。</Text>
311
+ </Box>
312
+ <Box marginTop={1}>
313
+ <Text color="yellow">按 Enter 开始配置 →</Text>
314
+ </Box>
315
+ </Box>
316
+ )}
317
+
318
+ {/* ── Mode Selection ── */}
319
+ {state.step === 'mode' && (
320
+ <Box flexDirection="column">
321
+ <Box marginBottom={1}>
322
+ <Text bold>选择模型连接方式:</Text>
323
+ </Box>
324
+ <Box marginBottom={1}>
325
+ <Text color={selectedIdx === 0 ? selectedColor : dimColor}>
326
+ {selectedIdx === 0 ? '▶' : ' '} 🌐 云端 AI 模型(推荐)
327
+ </Text>
328
+ </Box>
329
+ <Box marginBottom={1}>
330
+ <Text dimColor> Claude · DeepSeek · Gemini · 豆包 · Qwen · 混元 · OpenAI</Text>
331
+ </Box>
332
+ <Box marginBottom={1}>
333
+ <Text color={selectedIdx === 1 ? selectedColor : dimColor}>
334
+ {selectedIdx === 1 ? '▶' : ' '} 🖥️ 本地模型(Ollama)
335
+ </Text>
336
+ </Box>
337
+ <Box marginBottom={1}>
338
+ <Text dimColor> 需要提前安装 Ollama 并下载模型</Text>
339
+ </Box>
340
+ <Box marginTop={1}>
341
+ <Text dimColor>↑↓ 选择 · Enter 确认 · Esc 跳过</Text>
342
+ </Box>
343
+ </Box>
344
+ )}
345
+
346
+ {/* ── Cloud: Provider Selection ── */}
347
+ {state.step === 'cloud-provider' && (
348
+ <Box flexDirection="column">
349
+ <Box marginBottom={1}>
350
+ <Text bold>选择 AI 模型提供商:</Text>
351
+ </Box>
352
+ {CLOUD_PROVIDERS.map((p, i) => (
353
+ <Box key={p.id} marginBottom={1}>
354
+ <Text color={i === selectedIdx ? selectedColor : undefined}>
355
+ {i === selectedIdx ? '▶' : ' '} {p.name}
356
+ </Text>
357
+ <Text dimColor> ({p.models.filter((m) => m.status === 'active').length} 个模型)</Text>
358
+ </Box>
359
+ ))}
360
+ <Box marginTop={1}>
361
+ <Text dimColor>↑↓ 选择 · Enter 确认 · Esc 返回</Text>
362
+ </Box>
363
+ </Box>
364
+ )}
365
+
366
+ {/* ── Cloud: Model Selection ── */}
367
+ {state.step === 'cloud-model' && state.providerId && (
368
+ <Box flexDirection="column">
369
+ <Box marginBottom={1}>
370
+ <Text bold>
371
+ 选择模型({DEFAULT_PROVIDERS.find((p) => p.id === state.providerId)?.name}):
372
+ </Text>
373
+ </Box>
374
+ {getModels(state.providerId).map((m, i) => (
375
+ <Box key={m.id} marginBottom={1}>
376
+ <Text color={i === selectedIdx ? selectedColor : undefined}>
377
+ {i === selectedIdx ? '▶' : ' '} {m.name}
378
+ </Text>
379
+ <Text dimColor> ({m.id})</Text>
380
+ </Box>
381
+ ))}
382
+ <Box marginTop={1}>
383
+ <Text dimColor>↑↓ 选择 · Enter 确认 · Esc 返回</Text>
384
+ </Box>
385
+ </Box>
386
+ )}
387
+
388
+ {/* ── Cloud: API Key ── */}
389
+ {state.step === 'cloud-key' && state.providerId && state.modelId && (
390
+ <Box flexDirection="column">
391
+ <Box marginBottom={1}>
392
+ <Text bold>粘贴 API Key:</Text>
393
+ </Box>
394
+ <Box marginBottom={1}>
395
+ <Text dimColor>
396
+ 提供商:{DEFAULT_PROVIDERS.find((p) => p.id === state.providerId)?.name}
397
+ </Text>
398
+ </Box>
399
+ <Box marginBottom={1}>
400
+ <Text dimColor>
401
+ 模型:{getModels(state.providerId).find((m) => m.id === state.modelId)?.name}
402
+ </Text>
403
+ </Box>
404
+ {state.error && (
405
+ <Box marginBottom={1}>
406
+ <Text color="red">⚠ {state.error}</Text>
407
+ </Box>
408
+ )}
409
+ <Box>
410
+ <Text color="yellow">🔑 </Text>
411
+ <TextInput
412
+ value={inputValue}
413
+ onChange={setInputValue}
414
+ onSubmit={confirmApiKey}
415
+ placeholder="粘贴 API Key 后按 Enter..."
416
+ />
417
+ </Box>
418
+ <Box marginTop={1}>
419
+ <Text dimColor>Enter 确认 · Esc 返回</Text>
420
+ </Box>
421
+ </Box>
422
+ )}
423
+
424
+ {/* ── Local: Ollama ── */}
425
+ {state.step === 'local-ollama' && (
426
+ <Box flexDirection="column">
427
+ <Box marginBottom={1}>
428
+ <Text bold>Ollama 本地模型配置:</Text>
429
+ </Box>
430
+ {ollamaStatus ? (
431
+ <Box flexDirection="column" marginBottom={1}>
432
+ <Text>
433
+ 状态:{ollamaStatus.installed ? '✅ 已安装' : '❌ 未安装'}
434
+ {ollamaStatus.running ? ' · 运行中' : ''}
435
+ </Text>
436
+ {ollamaStatus.models.length > 0 && (
437
+ <Box marginTop={1}>
438
+ <Text dimColor>已下载模型:{ollamaStatus.models.join(', ')}</Text>
439
+ </Box>
440
+ )}
441
+ </Box>
442
+ ) : (
443
+ <Box marginBottom={1}>
444
+ <Text dimColor>正在检测 Ollama...</Text>
445
+ </Box>
446
+ )}
447
+ <Box marginBottom={1}>
448
+ <Text dimColor>请输入 Ollama 模型名称(如 llama3.2, qwen2.5, deepseek-r1):</Text>
449
+ </Box>
450
+ <Box>
451
+ <Text color="yellow">🖥️ </Text>
452
+ <TextInput
453
+ value={inputValue}
454
+ onChange={setInputValue}
455
+ onSubmit={finishLocal}
456
+ placeholder="ollama 模型名,默认 llama3.2"
457
+ />
458
+ </Box>
459
+ <Box marginTop={1}>
460
+ <Text dimColor>Enter 确认 · Esc 返回</Text>
461
+ </Box>
462
+ </Box>
463
+ )}
464
+
465
+ {/* ── Confirm ── */}
466
+ {state.step === 'confirm' && (
467
+ <Box flexDirection="column">
468
+ <Box marginBottom={1}>
469
+ <Text bold color="cyan">
470
+ 配置确认:
471
+ </Text>
472
+ </Box>
473
+ <Box marginBottom={1}>
474
+ <Text>连接方式:{state.mode === 'cloud' ? '🌐 云端' : '🖥️ 本地'}</Text>
475
+ </Box>
476
+ <Box marginBottom={1}>
477
+ <Text>提供商:{DEFAULT_PROVIDERS.find((p) => p.id === state.providerId)?.name}</Text>
478
+ </Box>
479
+ <Box marginBottom={1}>
480
+ <Text>模型:{state.modelId}</Text>
481
+ </Box>
482
+ <Box marginBottom={1}>
483
+ <Text>
484
+ API Key:{state.apiKey.slice(0, 8)}...{state.apiKey.slice(-4)}
485
+ </Text>
486
+ </Box>
487
+ <Box marginBottom={1}>
488
+ <Text dimColor>配置文件将保存到 ~/.mipham/config.yml</Text>
489
+ </Box>
490
+ {state.error && (
491
+ <Box marginBottom={1}>
492
+ <Text color="red">⚠ {state.error}</Text>
493
+ </Box>
494
+ )}
495
+ <Box marginTop={1}>
496
+ <Text color="yellow">按 Enter 保存并开始 →</Text>
497
+ </Box>
498
+ <Box marginTop={1}>
499
+ <Text dimColor>Backspace 返回修改 · Esc 跳过配置</Text>
500
+ </Box>
501
+ </Box>
502
+ )}
503
+
504
+ {/* ── Done ── */}
505
+ {state.step === 'done' && (
506
+ <Box flexDirection="column">
507
+ <Box marginBottom={1}>
508
+ <Text color="green" bold>
509
+ ✅ 配置完成!
510
+ </Text>
511
+ </Box>
512
+ <Box marginBottom={1}>
513
+ <Text>Mipham Code 正在启动...</Text>
514
+ </Box>
515
+ </Box>
516
+ )}
517
+ </Box>
518
+ )
519
+ }