@miphamai/cli 0.53.0 → 0.55.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.
@@ -29,6 +29,8 @@ import { parseTelegramEnv } from './telegram/env.js'
29
29
  import type { TelegramConfig } from './telegram/types.js'
30
30
  import { parseWecomEnv } from './wecom/env.js'
31
31
  import type { WecomConfig } from './wecom/types.js'
32
+ import { parseDingtalkEnv } from './dingtalk/env.js'
33
+ import type { DingtalkConfig } from './dingtalk/types.js'
32
34
  import { loadConfig } from '../config/loader'
33
35
 
34
36
  const HOME = homedir()
@@ -210,6 +212,20 @@ export async function startDaemon(): Promise<{ port: number; token: string }> {
210
212
  }
211
213
  }
212
214
 
215
+ // 钉钉 remote-control adapter(Stream 长连接,env 未配置时跳过)
216
+ const dingtalkConfig = parseDingtalkEnv()
217
+ let dingtalk: { config: DingtalkConfig; cwd: string; provider: string; model: string } | undefined
218
+ if (dingtalkConfig) {
219
+ const cfg = loadConfig()
220
+ const provider = cfg.providers.find((p) => p.status !== 'upcoming') ?? cfg.providers[0]
221
+ dingtalk = {
222
+ config: dingtalkConfig,
223
+ cwd: process.env.DINGTALK_CWD || process.cwd(),
224
+ provider: provider?.id ?? 'anthropic',
225
+ model: provider?.models?.[0]?.id ?? 'claude-sonnet-5',
226
+ }
227
+ }
228
+
213
229
  // Start HTTP server (Bun.serve starts listening immediately)
214
230
  const server = createServer({
215
231
  db,
@@ -227,6 +243,7 @@ export async function startDaemon(): Promise<{ port: number; token: string }> {
227
243
  feishu,
228
244
  telegram,
229
245
  wecom,
246
+ dingtalk,
230
247
  })
231
248
  activeServer = server
232
249
 
@@ -32,6 +32,9 @@ import type { TelegramConfig } from './telegram/types.js'
32
32
  import { createWecomAdapter } from './wecom/adapter.js'
33
33
  import { createWecomApi } from './wecom/api.js'
34
34
  import type { WecomConfig } from './wecom/types.js'
35
+ import { createDingtalkAdapter } from './dingtalk/adapter.js'
36
+ import { createDingtalkApi } from './dingtalk/api.js'
37
+ import type { DingtalkConfig } from './dingtalk/types.js'
35
38
  import { startHeartbeat } from './heartbeat'
36
39
 
37
40
  interface ServerConfig {
@@ -50,6 +53,7 @@ interface ServerConfig {
50
53
  feishu?: { config: FeishuConfig; cwd: string; provider: string; model: string }
51
54
  telegram?: { config: TelegramConfig; cwd: string; provider: string; model: string }
52
55
  wecom?: { config: WecomConfig; cwd: string; provider: string; model: string }
56
+ dingtalk?: { config: DingtalkConfig; cwd: string; provider: string; model: string }
53
57
  }
54
58
 
55
59
  interface WsData {
@@ -129,6 +133,7 @@ export function createServer(config: ServerConfig): Server<WsData> {
129
133
  feishu,
130
134
  telegram,
131
135
  wecom,
136
+ dingtalk,
132
137
  } = config
133
138
 
134
139
  const wsClients = new Map<string, Set<ServerWebSocket<WsData>>>()
@@ -292,6 +297,19 @@ export function createServer(config: ServerConfig): Server<WsData> {
292
297
  : undefined
293
298
  wecomAdapter?.start()
294
299
 
300
+ // ── 钉钉 remote-control adapter(Stream 长连接,无需 webhook 路由)──
301
+ const dingtalkAdapter = dingtalk
302
+ ? createDingtalkAdapter(dingtalk.config, createDingtalkApi(dingtalk.config), {
303
+ sm,
304
+ getOrCreateWorker,
305
+ rateLimiter,
306
+ cwd: dingtalk.cwd,
307
+ provider: dingtalk.provider,
308
+ model: dingtalk.model,
309
+ })
310
+ : undefined
311
+ dingtalkAdapter?.start()
312
+
295
313
  // ── 心跳式通知:定时扫 pending(goal/schedule),只通知、不自主行动 ──
296
314
  const heartbeatSource = {
297
315
  listGoals: () => sm.listSessions().flatMap((s) => goalManager.getGoals(s.id)),
@@ -1,5 +1,6 @@
1
1
  import type { TelegramApi } from './api.js'
2
2
  import type { TelegramMessage } from './types.js'
3
+ import { nextBackoff } from '../backoff.js'
3
4
 
4
5
  /** 从 update 提取文本消息;非文本/缺 message → null。chat.id 统一字符串化比对(id < 2^53;超范围 bigint 后续再引入)。 */
5
6
  export function extractTextMessage(update: unknown): TelegramMessage | null {
@@ -16,11 +17,6 @@ export function nextOffset(updates: Array<{ update_id?: number }>, prevOffset: n
16
17
  return Math.max(...updates.map((u) => u.update_id ?? prevOffset)) + 1
17
18
  }
18
19
 
19
- /** 指数退避,封顶 30s。 */
20
- export function nextBackoff(currentMs: number): number {
21
- return Math.min(currentMs * 2, 30_000)
22
- }
23
-
24
20
  /** 长轮询循环(unref 不阻退出)。返回 stop 函数。循环行为由 Task 7 集成测试覆盖。 */
25
21
  export function startTelegramPoller(
26
22
  api: TelegramApi,
@@ -1,10 +1,6 @@
1
1
  import type { WecomApi } from './api.js'
2
2
  import type { WecomMessage } from './types.js'
3
-
4
- /** 指数退避,封顶 30s。与 telegram poller.nextBackoff 同源。 */
5
- export function nextBackoff(currentMs: number): number {
6
- return Math.min(currentMs * 2, 30_000)
7
- }
3
+ import { nextBackoff } from '../backoff.js'
8
4
 
9
5
  /** WebSocket 长连接生命周期:建连→subscribe→心跳→消息回调→断开重连。返回 stop。 */
10
6
  export function startWecomWs(
@@ -563,6 +563,7 @@
563
563
  "cd": {
564
564
  "usage": "Usage: /cd <path>\n\nChange the session working directory.\nExample: /cd ~/projects/my-app\n\nCurrent: {cwd}",
565
565
  "not_found": "❌ Directory not found: {path}",
566
+ "suggestions": "Did you mean:",
566
567
  "failed": "❌ Failed to change directory: {error}",
567
568
  "title": "── Directory Changed ──",
568
569
  "content": "New CWD: {path}\n\nThe AI will now work relative to this directory.\nNote: This changes the filesystem root for tools like Read, Write, Bash."
@@ -594,7 +595,10 @@
594
595
  "git_section": "── Git ──",
595
596
  "no_git": " (not a git repository or git not available)",
596
597
  "skills_section": "── Skills ──",
597
- "skills_unavailable": " (skills info unavailable)"
598
+ "skills_unavailable": " (skills info unavailable)",
599
+ "audit_section": "── CLAUDE.md Audit ──",
600
+ "audit_clean": " ✓ no code-derivable sections found",
601
+ "audit_found": "Add the sections above to the `prompt-exclude` frontmatter to stop sending them to the model."
598
602
  },
599
603
  "plugins": {
600
604
  "unavailable": "PluginManager not available in this session.",
@@ -563,6 +563,7 @@
563
563
  "cd": {
564
564
  "usage": "用法: /cd <path>\n\n更改会话工作目录。\n示例: /cd ~/projects/my-app\n\n当前: {cwd}",
565
565
  "not_found": "❌ 目录未找到: {path}",
566
+ "suggestions": "你是想找:",
566
567
  "failed": "❌ 更改目录失败: {error}",
567
568
  "title": "── 目录已更改 ──",
568
569
  "content": "新目录: {path}\n\nAI 将相对于此目录工作。\n注意: 这会更改 Read、Write、Bash 等工具的文件系统根路径。"
@@ -594,7 +595,10 @@
594
595
  "git_section": "── Git ──",
595
596
  "no_git": " (不是 git 仓库或 git 不可用)",
596
597
  "skills_section": "── 技能 ──",
597
- "skills_unavailable": " (技能信息不可用)"
598
+ "skills_unavailable": " (技能信息不可用)",
599
+ "audit_section": "── CLAUDE.md 审计 ──",
600
+ "audit_clean": " ✓ 未发现可从代码推断的冗余章节",
601
+ "audit_found": "将上述章节加入 `prompt-exclude` frontmatter,可停止发送给模型、节省 token。"
598
602
  },
599
603
  "plugins": {
600
604
  "unavailable": "当前会话中 PluginManager 不可用。",
package/src/index.tsx CHANGED
@@ -28,7 +28,7 @@ import { QueryEngine } from './core/engine'
28
28
  import { ExperienceRuleEngine } from './core/rule-engine.js'
29
29
  import { SessionLog } from './core/session-log'
30
30
  import { SessionStore } from './core/session-store'
31
- import type { PermissionLevel, MiphamConfig } from './shared/types'
31
+ import type { PermissionLevel, MiphamConfig, McpServerConfig } from './shared/types'
32
32
  import { SkillsLoader } from './skills/loader'
33
33
  import { PluginManager } from './plugin/plugin-manager'
34
34
  import { loadPlugins } from './plugin/plugin-loader'
@@ -194,6 +194,38 @@ function SetupGate(props: SetupGateProps) {
194
194
  })
195
195
  }
196
196
 
197
+ /**
198
+ * Connect all MCP servers in parallel and register their tools into the shared
199
+ * registry. Runs lazily in the background so a slow or dead server (e.g. a 15s
200
+ * connect timeout) never blocks first paint. Failures are non-fatal.
201
+ */
202
+ async function connectMcpServers(
203
+ mcpServers: McpServerConfig[],
204
+ tools: ReturnType<typeof createToolRegistry>,
205
+ ): Promise<void> {
206
+ if (mcpServers.length === 0) return
207
+ const mcp = McpClient.getInstance()
208
+ // Wire runtime tool-list changes before connect so mid-connect updates land.
209
+ syncMcpToolsOnChange(mcp, tools)
210
+ const results = await Promise.allSettled(
211
+ mcpServers.map(async (server) => {
212
+ await mcp.connect(server)
213
+ const count = registerMcpServerTools(server.name, tools)
214
+ if (count > 0) {
215
+ process.stderr.write(`[mcp] "${server.name}": registered ${count} tools\n`)
216
+ }
217
+ }),
218
+ )
219
+ for (let i = 0; i < results.length; i++) {
220
+ const result = results[i]!
221
+ if (result.status === 'rejected') {
222
+ process.stderr.write(
223
+ `[mcp] Failed to connect "${mcpServers[i]!.name}": ${String(result.reason)}\n`,
224
+ )
225
+ }
226
+ }
227
+ }
228
+
197
229
  export async function runApp(options: RunOptions): Promise<void> {
198
230
  // Metrics: count CLI invocation + active session
199
231
  getMetrics().cliInvocations.inc()
@@ -455,33 +487,10 @@ export async function runApp(options: RunOptions): Promise<void> {
455
487
  mountSkills(vajraContext, skillsLoader)
456
488
  mountLlm(vajraContext, registry)
457
489
 
458
- // Connect MCP servers and register their tools into the tool registry.
459
- // Uses Promise.allSettled for parallel connection — failures are non-fatal.
460
- const mcpServers = config.skills?.mcpServers ?? []
461
- if (mcpServers.length > 0) {
462
- const mcp = McpClient.getInstance()
463
- const results = await Promise.allSettled(
464
- mcpServers.map(async (server) => {
465
- await mcp.connect(server)
466
- const count = registerMcpServerTools(server.name, tools)
467
- if (count > 0) {
468
- process.stderr.write(`[mcp] "${server.name}": registered ${count} tools\n`)
469
- }
470
- }),
471
- )
472
- // Log failures (non-fatal — app starts without that server's tools)
473
- for (let i = 0; i < results.length; i++) {
474
- const result = results[i]!
475
- if (result.status === 'rejected') {
476
- process.stderr.write(
477
- `[mcp] Failed to connect "${mcpServers[i]!.name}": ${String(result.reason)}\n`,
478
- )
479
- }
480
- }
481
- }
482
-
483
- // Wire runtime MCP tool changes (tools/list_changed) into the central registry.
484
- syncMcpToolsOnChange(McpClient.getInstance(), tools)
490
+ // Connect MCP servers lazily in the background: the UI renders immediately,
491
+ // and each server's tools register into the shared registry as it connects.
492
+ // A dead server (e.g. a 15s connect timeout) no longer blocks startup.
493
+ void connectMcpServers(config.skills?.mcpServers ?? [], tools)
485
494
 
486
495
  // Initialize hook engine — register skill-defined hooks
487
496
  const hookEngine = new HookEngine()
@@ -549,12 +558,20 @@ export async function runApp(options: RunOptions): Promise<void> {
549
558
  // so `@mipham-code` can address this session; uniqueness is enforced against
550
559
  // other live sessions (suffix -2, -3, …). The id stays a stable `session-<ts>`.
551
560
  const defaultName = basename(process.cwd()) || 'session'
561
+
562
+ // Load cross-session config first so the registered SessionInfo carries this
563
+ // session's inbound policy — senders discover it and report "refused" instead
564
+ // of a silent drop when policy is 'deny'.
565
+ const crossSessionConfig = loadCrossSessionConfig(process.cwd())
566
+ engine.setCrossSessionConfig(crossSessionConfig)
567
+
552
568
  const sessionInfo = createSessionInfo(
553
569
  sessionName,
554
570
  ensureUniqueSessionName(defaultName, discoverSessions()),
555
571
  process.cwd(),
556
572
  defaultProvider,
557
573
  defaultModel,
574
+ crossSessionConfig.crossSessionInbound,
558
575
  )
559
576
  registerActiveSession(sessionInfo)
560
577
 
@@ -568,10 +585,6 @@ export async function runApp(options: RunOptions): Promise<void> {
568
585
  heartbeatInterval.unref()
569
586
  }
570
587
 
571
- // Load cross-session config and wire into the engine's inbound policy
572
- const crossSessionConfig = loadCrossSessionConfig(process.cwd())
573
- engine.setCrossSessionConfig(crossSessionConfig)
574
-
575
588
  // P2-1: Trigger SessionStart hooks after full initialization
576
589
  hookEngine.executeSessionStart(sessionName).catch(() => {
577
590
  // Hook failures never block session startup
package/src/mcp/client.ts CHANGED
@@ -161,8 +161,8 @@ export class McpClient {
161
161
  if (existing?.status === 'connected') return
162
162
 
163
163
  const transport: StdioTransport | HttpTransport = config.url
164
- ? new HttpTransport()
165
- : new StdioTransport()
164
+ ? new HttpTransport(undefined, config.request_timeout_ms)
165
+ : new StdioTransport(config.request_timeout_ms)
166
166
  const protocol = new McpProtocol(transport)
167
167
 
168
168
  const connection: ActiveConnection = {
@@ -1,10 +1,9 @@
1
1
  import type { JsonRpcRequest, JsonRpcResponse, JsonRpcNotification, JsonRpcError } from './types'
2
2
  import type { Transport, NotificationHandler } from './transport'
3
+ import { DEFAULT_REQUEST_TIMEOUT_MS, requestTimeoutError } from './transport'
3
4
 
4
5
  type FetchFn = (input: string, init?: RequestInit) => Promise<Response>
5
6
 
6
- const REQUEST_TIMEOUT_MS = 60_000
7
-
8
7
  /**
9
8
  * Merge multiple SSE `data:` events into a single JSON-RPC result.
10
9
  *
@@ -74,7 +73,10 @@ export class HttpTransport implements Transport {
74
73
  private closed = false
75
74
  private notificationHandlers: NotificationHandler[] = []
76
75
 
77
- constructor(private fetchImpl: FetchFn = fetch as unknown as FetchFn) {}
76
+ constructor(
77
+ private fetchImpl: FetchFn = fetch as unknown as FetchFn,
78
+ private requestTimeoutMs = DEFAULT_REQUEST_TIMEOUT_MS,
79
+ ) {}
78
80
 
79
81
  async start(
80
82
  url: string,
@@ -107,7 +109,7 @@ export class HttpTransport implements Transport {
107
109
  }
108
110
 
109
111
  const controller = new AbortController()
110
- const timer = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS)
112
+ const timer = setTimeout(() => controller.abort(), this.requestTimeoutMs)
111
113
 
112
114
  try {
113
115
  const response = await this.fetchImpl(this.url, {
@@ -142,6 +144,11 @@ export class HttpTransport implements Transport {
142
144
  throw new Error(`MCP error ${json.error.code}: ${json.error.message}`)
143
145
  }
144
146
  return json.result
147
+ } catch (err) {
148
+ if (controller.signal.aborted) {
149
+ throw requestTimeoutError(method, this.requestTimeoutMs)
150
+ }
151
+ throw err
145
152
  } finally {
146
153
  clearTimeout(timer)
147
154
  }
package/src/mcp/oauth.ts CHANGED
@@ -4,6 +4,7 @@ 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 { fetchWithRetry } from '../providers/fetch-utils'
7
8
  import { createT } from '../i18n-core/t'
8
9
  import enUS from '../i18n-core/locales/en-US.json'
9
10
  import zhCN from '../i18n-core/locales/zh-CN.json'
@@ -148,15 +149,22 @@ export class OAuthClient {
148
149
  throw new Error(`No refresh token available for "${serverName}"`)
149
150
  }
150
151
  const auth = config.auth!
151
- const response = await fetch(auth.tokenUrl, {
152
- method: 'POST',
153
- headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
154
- body: new URLSearchParams({
155
- grant_type: 'refresh_token',
156
- refresh_token: saved.refreshToken,
157
- client_id: auth.clientId,
158
- }).toString(),
159
- })
152
+ // A single failed refresh is often a transient network/server error, not a
153
+ // real revocation — retry (5xx/429/network) before forcing PKCE. 4xx (e.g.
154
+ // invalid_grant) is a genuine revocation and falls through to PKCE directly.
155
+ const response = await fetchWithRetry(
156
+ auth.tokenUrl,
157
+ {
158
+ method: 'POST',
159
+ headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
160
+ body: new URLSearchParams({
161
+ grant_type: 'refresh_token',
162
+ refresh_token: saved.refreshToken,
163
+ client_id: auth.clientId,
164
+ }).toString(),
165
+ },
166
+ { maxRetries: 1, baseDelay: 300 },
167
+ )
160
168
  if (!response.ok) {
161
169
  this.store.delete(serverName)
162
170
  const fresh = await this.executePkceFlow(config)
@@ -19,7 +19,12 @@ export interface Transport {
19
19
  isConnected(): boolean
20
20
  }
21
21
 
22
- const REQUEST_TIMEOUT_MS = 60_000
22
+ export const DEFAULT_REQUEST_TIMEOUT_MS = 60_000
23
+
24
+ /** Shared timeout error for both stdio and HTTP transports. */
25
+ export function requestTimeoutError(method: string, ms: number): Error {
26
+ return new Error(`MCP request timeout: ${method} (${ms}ms)`)
27
+ }
23
28
 
24
29
  // ── Environment variable security: block sensitive vars from MCP subprocess ──
25
30
  //
@@ -69,6 +74,7 @@ function buildProcEnv(extra?: Record<string, string>): Record<string, string> {
69
74
  * Uses Node.js child_process for portability across Bun and Node runtimes.
70
75
  */
71
76
  export class StdioTransport implements Transport {
77
+ constructor(private requestTimeoutMs = DEFAULT_REQUEST_TIMEOUT_MS) {}
72
78
  private proc: ChildProcess | null = null
73
79
  private msgId = 0
74
80
  private pending = new Map<
@@ -169,8 +175,8 @@ export class StdioTransport implements Transport {
169
175
  return new Promise((resolve, reject) => {
170
176
  const timer = setTimeout(() => {
171
177
  this.pending.delete(id)
172
- reject(new Error(`MCP request timeout: ${method} (${REQUEST_TIMEOUT_MS}ms)`))
173
- }, REQUEST_TIMEOUT_MS)
178
+ reject(requestTimeoutError(method, this.requestTimeoutMs))
179
+ }, this.requestTimeoutMs)
174
180
 
175
181
  this.pending.set(id, { resolve, reject, timer })
176
182
 
@@ -0,0 +1,17 @@
1
+ import { writeFileSync, renameSync } from 'node:fs'
2
+
3
+ /**
4
+ * Write a file atomically: write to a same-directory `.tmp` file, then rename
5
+ * over the target. Same-filesystem rename is atomic, so a crash or kill
6
+ * mid-write can never leave a truncated/corrupt file — readers see either the
7
+ * old or the new content, never a partial write.
8
+ */
9
+ export function atomicWriteFileSync(
10
+ path: string,
11
+ content: string,
12
+ options: { mode?: number } = {},
13
+ ): void {
14
+ const tmp = path + '.tmp'
15
+ writeFileSync(tmp, content, { encoding: 'utf-8', mode: options.mode ?? 0o600 })
16
+ renameSync(tmp, path)
17
+ }
@@ -9,7 +9,7 @@
9
9
  export const PACKAGE_NAME = '@miphamai/cli' as const
10
10
 
11
11
  /** 当前发布版本 */
12
- export const PACKAGE_VERSION = '0.53.0' as const
12
+ export const PACKAGE_VERSION = '0.55.0' as const
13
13
 
14
14
  /** npm install 全局安装命令 */
15
15
  export const NPM_INSTALL_COMMAND = `npm install -g ${PACKAGE_NAME}` as const
@@ -176,6 +176,8 @@ export interface McpServerConfig {
176
176
  /** HTTP: extra request headers (e.g. Authorization). */
177
177
  headers?: Record<string, string>
178
178
  env?: Record<string, string>
179
+ /** Per-server tool-call request timeout (ms). Overrides the 60s default. */
180
+ request_timeout_ms?: number
179
181
  auth?: {
180
182
  type: 'oauth'
181
183
  authorizationUrl: string
@@ -428,6 +430,7 @@ export interface SessionInfo {
428
430
  cwd?: string
429
431
  provider?: string
430
432
  model?: string
433
+ crossSessionInbound?: CrossSessionInbound
431
434
  }
432
435
 
433
436
  // ── Background Agent Types ──
@@ -13,9 +13,12 @@ interface FrontmatterResult {
13
13
  }
14
14
 
15
15
  function parseFrontmatter(raw: string): FrontmatterResult {
16
- const match = raw.match(/^---\n([\s\S]*?)\n---\n([\s\S]*)$/)
16
+ // Strip a leading UTF-8 BOM — otherwise `^---` never matches and a
17
+ // BOM-prefixed file is silently treated as body text (effectively ignored).
18
+ const src = raw.replace(/^\uFEFF/, '')
19
+ const match = src.match(/^---\n([\s\S]*?)\n---\n([\s\S]*)$/)
17
20
  if (!match) {
18
- return { data: {}, content: raw }
21
+ return { data: {}, content: src }
19
22
  }
20
23
  return {
21
24
  data: parseYaml(match[1] || '') as Record<string, unknown>,
@@ -11,6 +11,8 @@ import type { PluginManager } from '../plugin/plugin-manager'
11
11
  import type { Message } from '../shared/types.js'
12
12
  import { McpClient } from '../mcp/client'
13
13
  import { buildCapabilityReport } from '../core/capability-inventory'
14
+ import { InstructionsLoader } from '../core/instructions'
15
+ import { findDerivableSections, DERIVABLE_HINTS } from '../core/claude-md-audit'
14
16
  import { runCrsiModification, approvePending, rejectPending, hasPending } from '../core/crsi-modify'
15
17
  import {
16
18
  produceCrsiProposal,
@@ -57,6 +59,7 @@ import {
57
59
  } from '../commands/project.js'
58
60
  import { themeCmd, releaseNotesCmd, ideCmd, terminalSetupCmd } from '../commands/environment.js'
59
61
  import { commitCmd, pushCmd, prCmd, issueCmd } from '../commands/git.js'
62
+ import { suggestDirectories } from '../commands/cd-suggest.js'
60
63
  import { keysCmd } from '../commands/keys'
61
64
  import { workflowViewCmd, workflowWatchCmd } from '../commands/workflow-view.js'
62
65
  import { execSync } from 'node:child_process'
@@ -2905,6 +2908,34 @@ const doctorCmd: CommandHandler = async (ctx) => {
2905
2908
  }
2906
2909
  }
2907
2910
 
2911
+ // CLAUDE.md audit — flag sections the model can infer from the codebase
2912
+ try {
2913
+ const loader = new InstructionsLoader()
2914
+ loader.loadAll(process.cwd())
2915
+ const claudeFiles = loader.list().filter((f) => f.path.endsWith('CLAUDE.md'))
2916
+
2917
+ lines.push('')
2918
+ lines.push(t('commands.doctor.audit_section'))
2919
+ let found = false
2920
+ for (const file of claudeFiles) {
2921
+ const sections = findDerivableSections(file.content)
2922
+ if (sections.length === 0) continue
2923
+ found = true
2924
+ lines.push(` ${file.path}`)
2925
+ for (const s of sections) {
2926
+ lines.push(` - "${s.heading}" — ${DERIVABLE_HINTS[s.reason]}`)
2927
+ }
2928
+ }
2929
+ if (!found) {
2930
+ lines.push(t('commands.doctor.audit_clean'))
2931
+ } else {
2932
+ lines.push('')
2933
+ lines.push(t('commands.doctor.audit_found'))
2934
+ }
2935
+ } catch {
2936
+ // best-effort — skip the audit on any failure
2937
+ }
2938
+
2908
2939
  return { content: lines.join('\n') }
2909
2940
  }
2910
2941
 
@@ -3183,7 +3214,12 @@ const cdCmd: CommandHandler = async (ctx, args) => {
3183
3214
  const resolved = resolve(target.replace(/^~/, process.env.HOME || '~'))
3184
3215
 
3185
3216
  if (!existsSync(resolved)) {
3186
- return { content: t('commands.cd.not_found', { path: resolved }) }
3217
+ const suggestions = suggestDirectories(resolved)
3218
+ let content = t('commands.cd.not_found', { path: resolved })
3219
+ if (suggestions.length > 0) {
3220
+ content += `\n\n${t('commands.cd.suggestions')}\n${suggestions.map((s) => ` ${s}`).join('\n')}`
3221
+ }
3222
+ return { content }
3187
3223
  }
3188
3224
 
3189
3225
  try {
@@ -14,7 +14,8 @@ import { useI18n } from '../i18n-context'
14
14
  import TextInput from 'ink-text-input'
15
15
  import { DEFAULT_PROVIDERS, OLLAMA_PRESET_MODELS } from '../shared/constants'
16
16
  import type { ModelInfo } from '../shared/types'
17
- import { mkdirSync, writeFileSync } from 'node:fs'
17
+ import { mkdirSync } from 'node:fs'
18
+ import { atomicWriteFileSync } from '../shared/atomic-write'
18
19
  import { join } from 'node:path'
19
20
  import { homedir } from 'node:os'
20
21
  import { execSync } from 'node:child_process'
@@ -115,7 +116,7 @@ function writeConfigFile(providerId: string, modelId: string, apiKey: string): v
115
116
  ...models.map((m) => ` - id: ${m.id}`),
116
117
  ]
117
118
 
118
- writeFileSync(join(configDir, 'config.yml'), lines.join('\n'), 'utf-8')
119
+ atomicWriteFileSync(join(configDir, 'config.yml'), lines.join('\n'))
119
120
  }
120
121
 
121
122
  function checkOllama(): { installed: boolean; running: boolean; models: string[] } {
@@ -1,95 +0,0 @@
1
- /**
2
- * OutputStylesLoader — loads custom response persona styles from .mipham/output-styles/.
3
- *
4
- * Each .md file in the directory is a named style. The active style's content
5
- * is injected into the system prompt to shape the AI's tone and communication style.
6
- *
7
- * Usage:
8
- * const loader = new OutputStylesLoader(cwd)
9
- * loader.list() // ['concise', 'academic', ...]
10
- * loader.get('concise') // style content or undefined
11
- * loader.setActive('concise') // switch active style
12
- * loader.getActiveContent() // active style content (for system prompt)
13
- */
14
-
15
- import { readdirSync, readFileSync, existsSync } from 'node:fs'
16
- import { join, basename, extname } from 'node:path'
17
-
18
- export class OutputStylesLoader {
19
- private stylesDir: string
20
- private activeStyle: string | null = null
21
-
22
- constructor(cwd: string) {
23
- this.stylesDir = join(cwd, '.mipham', 'output-styles')
24
- }
25
-
26
- /**
27
- * List all available style names (filename without .md extension).
28
- */
29
- list(): string[] {
30
- if (!existsSync(this.stylesDir)) return []
31
- try {
32
- return readdirSync(this.stylesDir)
33
- .filter((f) => f.endsWith('.md'))
34
- .map((f) => basename(f, extname(f)))
35
- } catch {
36
- return []
37
- }
38
- }
39
-
40
- /**
41
- * Get the content of a named style. Returns undefined if not found.
42
- */
43
- get(name: string): string | undefined {
44
- if (!existsSync(this.stylesDir)) return undefined
45
- const filepath = join(this.stylesDir, `${name}.md`)
46
- if (!existsSync(filepath)) return undefined
47
- try {
48
- return readFileSync(filepath, 'utf-8').trim()
49
- } catch {
50
- return undefined
51
- }
52
- }
53
-
54
- /**
55
- * Set the active output style.
56
- */
57
- setActive(name: string): boolean {
58
- const content = this.get(name)
59
- if (!content) return false
60
- this.activeStyle = name
61
- return true
62
- }
63
-
64
- /**
65
- * Clear the active output style (revert to default personality).
66
- */
67
- clearActive(): void {
68
- this.activeStyle = null
69
- }
70
-
71
- /**
72
- * Get the active style name (null if none selected).
73
- */
74
- getActiveName(): string | null {
75
- return this.activeStyle
76
- }
77
-
78
- /**
79
- * Get the active style content for system prompt injection.
80
- * Returns empty string if no style is active.
81
- */
82
- getActiveContent(): string {
83
- if (!this.activeStyle) return ''
84
- const content = this.get(this.activeStyle)
85
- if (!content) return ''
86
- return [
87
- `[Output Style: ${this.activeStyle}]`,
88
- 'Adopt the following communication style in all your responses:',
89
- '',
90
- content,
91
- '',
92
- '---',
93
- ].join('\n')
94
- }
95
- }