@miphamai/cli 0.53.0 → 0.54.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 +1 -1
- package/src/agent-view/agent-view-manager.ts +15 -6
- package/src/agent-view/dashboard.tsx +21 -2
- package/src/commands/cd-suggest.ts +35 -0
- package/src/core/claude-md-audit.ts +64 -0
- package/src/daemon/backoff.ts +4 -0
- package/src/daemon/channel-message.ts +3 -3
- package/src/daemon/dingtalk/adapter.ts +62 -0
- package/src/daemon/dingtalk/api.ts +120 -0
- package/src/daemon/dingtalk/env.ts +16 -0
- package/src/daemon/dingtalk/types.ts +13 -0
- package/src/daemon/dingtalk/ws-client.ts +81 -0
- package/src/daemon/index.ts +17 -0
- package/src/daemon/server.ts +18 -0
- package/src/daemon/telegram/poller.ts +1 -5
- package/src/daemon/wecom/ws-client.ts +1 -5
- package/src/i18n-core/locales/en-US.json +5 -1
- package/src/i18n-core/locales/zh-CN.json +5 -1
- package/src/mcp/client.ts +2 -2
- package/src/mcp/http-transport.ts +11 -4
- package/src/mcp/oauth.ts +17 -9
- package/src/mcp/transport.ts +9 -3
- package/src/shared/package-info.ts +1 -1
- package/src/shared/types.ts +2 -0
- package/src/ui/commands.ts +37 -1
package/package.json
CHANGED
|
@@ -250,17 +250,26 @@ export class AgentViewManager {
|
|
|
250
250
|
return true
|
|
251
251
|
}
|
|
252
252
|
|
|
253
|
+
/**
|
|
254
|
+
* Permanently remove a single session (any status). Returns true if found.
|
|
255
|
+
*/
|
|
256
|
+
remove(id: string): boolean {
|
|
257
|
+
if (!this.sessions.has(id)) return false
|
|
258
|
+
this.sessions.delete(id)
|
|
259
|
+
this.sessionOrder = this.sessionOrder.filter((oid) => oid !== id)
|
|
260
|
+
return true
|
|
261
|
+
}
|
|
262
|
+
|
|
253
263
|
/**
|
|
254
264
|
* Remove all completed and failed sessions (cleanup).
|
|
255
265
|
*/
|
|
256
266
|
prune(): number {
|
|
267
|
+
const terminalIds = Array.from(this.sessions.entries())
|
|
268
|
+
.filter(([, session]) => session.status === 'completed' || session.status === 'failed')
|
|
269
|
+
.map(([id]) => id)
|
|
257
270
|
let removed = 0
|
|
258
|
-
for (const
|
|
259
|
-
if (
|
|
260
|
-
this.sessions.delete(id)
|
|
261
|
-
this.sessionOrder = this.sessionOrder.filter((oid) => oid !== id)
|
|
262
|
-
removed++
|
|
263
|
-
}
|
|
271
|
+
for (const id of terminalIds) {
|
|
272
|
+
if (this.remove(id)) removed++
|
|
264
273
|
}
|
|
265
274
|
return removed
|
|
266
275
|
}
|
|
@@ -32,6 +32,8 @@ export function AgentViewDashboard({ manager, onAttach, onExit }: DashboardProps
|
|
|
32
32
|
const [peekingSessionId, setPeekingSessionId] = useState<string | null>(null)
|
|
33
33
|
const [groupBy, setGroupBy] = useState<'status' | 'directory'>('status')
|
|
34
34
|
const [feedback, setFeedback] = useState<string | null>(null)
|
|
35
|
+
// Bump to force flatList recompute after a session is removed (list membership change).
|
|
36
|
+
const [version, setVersion] = useState(0)
|
|
35
37
|
|
|
36
38
|
// Flash a brief feedback message that auto-clears
|
|
37
39
|
const showFeedback = useCallback((msg: string) => {
|
|
@@ -87,7 +89,7 @@ export function AgentViewDashboard({ manager, onAttach, onExit }: DashboardProps
|
|
|
87
89
|
}
|
|
88
90
|
|
|
89
91
|
return result
|
|
90
|
-
}, [manager, groupBy])
|
|
92
|
+
}, [manager, groupBy, version])
|
|
91
93
|
|
|
92
94
|
// Flatten sessions only for navigation (skip headers)
|
|
93
95
|
const sessionsOnly = useMemo(
|
|
@@ -140,6 +142,22 @@ export function AgentViewDashboard({ manager, onAttach, onExit }: DashboardProps
|
|
|
140
142
|
return
|
|
141
143
|
}
|
|
142
144
|
|
|
145
|
+
// Ctrl+X — permanently remove the selected session
|
|
146
|
+
if (key.ctrl && input === 'x') {
|
|
147
|
+
if (sessionsOnly.length === 0) {
|
|
148
|
+
showFeedback('No sessions to remove')
|
|
149
|
+
return
|
|
150
|
+
}
|
|
151
|
+
const current = sessionsOnly[selectedIndex]
|
|
152
|
+
if (!current) return
|
|
153
|
+
manager.remove(current.session.id)
|
|
154
|
+
setPeekingSessionId(null)
|
|
155
|
+
setSelectedIndex((prev) => Math.max(0, Math.min(prev, sessionsOnly.length - 2)))
|
|
156
|
+
setVersion((v) => v + 1)
|
|
157
|
+
showFeedback(`Removed ${current.session.title || current.session.id}`)
|
|
158
|
+
return
|
|
159
|
+
}
|
|
160
|
+
|
|
143
161
|
if (input === 'j') {
|
|
144
162
|
if (sessionsOnly.length === 0) {
|
|
145
163
|
showFeedback('No sessions to navigate — spawn a background agent first')
|
|
@@ -223,7 +241,8 @@ export function AgentViewDashboard({ manager, onAttach, onExit }: DashboardProps
|
|
|
223
241
|
</Box>
|
|
224
242
|
<Box>
|
|
225
243
|
<Text dimColor>
|
|
226
|
-
j/k navigate · Space peek · Enter attach · Ctrl+T group · Ctrl+R rename ·
|
|
244
|
+
j/k navigate · Space peek · Enter attach · Ctrl+T group · Ctrl+R rename · Ctrl+X remove
|
|
245
|
+
· Esc back
|
|
227
246
|
</Text>
|
|
228
247
|
</Box>
|
|
229
248
|
</Box>
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { existsSync, readdirSync } from 'node:fs'
|
|
2
|
+
import { dirname, basename, join } from 'node:path'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Suggest existing directories that match a partial `/cd` target.
|
|
6
|
+
*
|
|
7
|
+
* `path` must already be tilde-expanded and resolved by the caller (`/cd`
|
|
8
|
+
* passes the `resolve()`d target). Given a path that does not (fully) exist,
|
|
9
|
+
* walk up to its nearest existing ancestor and return the subdirectories whose
|
|
10
|
+
* names prefix-match the trailing segment. Files and non-matching entries are
|
|
11
|
+
* excluded. Returns `[]` when the path already exists or nothing matches.
|
|
12
|
+
*/
|
|
13
|
+
export function suggestDirectories(path: string): string[] {
|
|
14
|
+
if (existsSync(path)) return []
|
|
15
|
+
|
|
16
|
+
let ancestor = dirname(path)
|
|
17
|
+
while (!existsSync(ancestor)) {
|
|
18
|
+
const parent = dirname(ancestor)
|
|
19
|
+
if (parent === ancestor) return [] // hit filesystem root
|
|
20
|
+
ancestor = parent
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const prefix = basename(path)
|
|
24
|
+
let entries
|
|
25
|
+
try {
|
|
26
|
+
entries = readdirSync(ancestor, { withFileTypes: true })
|
|
27
|
+
} catch {
|
|
28
|
+
return []
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
return entries
|
|
32
|
+
.filter((e) => e.isDirectory() && e.name.startsWith(prefix))
|
|
33
|
+
.map((e) => join(ancestor, e.name))
|
|
34
|
+
.sort()
|
|
35
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CLAUDE.md audit — find sections whose content the model can infer from the
|
|
3
|
+
* codebase itself (directory structure, tech stack, dependencies, commit
|
|
4
|
+
* history, project/submodule catalogs, test counts). Such sections burn
|
|
5
|
+
* context tokens every session and go stale as the code changes; they are
|
|
6
|
+
* candidates for the `prompt-exclude` frontmatter.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
export type DerivableReason =
|
|
10
|
+
'structure' | 'tech-stack' | 'dependencies' | 'commits' | 'catalog' | 'tests'
|
|
11
|
+
|
|
12
|
+
export interface DerivableSection {
|
|
13
|
+
heading: string
|
|
14
|
+
reason: DerivableReason
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** Human-readable hint for each derivable category (diagnostic output). */
|
|
18
|
+
export const DERIVABLE_HINTS: Record<DerivableReason, string> = {
|
|
19
|
+
structure: 'directory/file structure — inferable from the repo itself',
|
|
20
|
+
'tech-stack': 'tech stack — inferable from package.json / config',
|
|
21
|
+
dependencies: 'dependencies — inferable from package.json',
|
|
22
|
+
commits: 'commit/revision history — inferable from git log',
|
|
23
|
+
catalog: 'project/submodule catalog — inferable from .gitmodules / directories',
|
|
24
|
+
tests: 'test counts — inferable from running the suite',
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const PATTERNS: Array<{ pattern: RegExp; reason: DerivableReason }> = [
|
|
28
|
+
{
|
|
29
|
+
pattern:
|
|
30
|
+
/目录结构|项目结构|文件结构|directory structure|project structure|file structure|repo structure|monorepo/i,
|
|
31
|
+
reason: 'structure',
|
|
32
|
+
},
|
|
33
|
+
{ pattern: /技术栈|tech\s*stack|technology\s*stack/i, reason: 'tech-stack' },
|
|
34
|
+
{ pattern: /依赖关系|依赖清单|dependencies/i, reason: 'dependencies' },
|
|
35
|
+
{
|
|
36
|
+
pattern: /最近提交|recent\s*commit|修订历史|revision\s*history|changelog|变更记录|变更历史/i,
|
|
37
|
+
reason: 'commits',
|
|
38
|
+
},
|
|
39
|
+
{
|
|
40
|
+
pattern: /项目一览|项目清单|项目列表|子模块清单|submodule\s*(list|catalog)|catalog/i,
|
|
41
|
+
reason: 'catalog',
|
|
42
|
+
},
|
|
43
|
+
{ pattern: /测试矩阵|test\s*matrix|测试覆盖|coverage/i, reason: 'tests' },
|
|
44
|
+
]
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Return `##`/`###` headings whose title matches a derivable-content pattern,
|
|
48
|
+
* in document order. Headings without a match are ignored.
|
|
49
|
+
*/
|
|
50
|
+
export function findDerivableSections(content: string): DerivableSection[] {
|
|
51
|
+
const found: DerivableSection[] = []
|
|
52
|
+
for (const line of content.split('\n')) {
|
|
53
|
+
const m = line.match(/^(#{2,3})\s+(.+?)\s*$/)
|
|
54
|
+
if (!m) continue
|
|
55
|
+
const heading = m[2]!.trim()
|
|
56
|
+
for (const { pattern, reason } of PATTERNS) {
|
|
57
|
+
if (pattern.test(heading)) {
|
|
58
|
+
found.push({ heading, reason })
|
|
59
|
+
break
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
return found
|
|
64
|
+
}
|
|
@@ -3,7 +3,7 @@ import type { SessionWorker } from './session-worker'
|
|
|
3
3
|
import type { RateLimiter } from './rate-limiter'
|
|
4
4
|
|
|
5
5
|
export interface ChannelMessageOptions {
|
|
6
|
-
channel: string // 'feishu' | 'telegram' | 'wecom'
|
|
6
|
+
channel: string // 'feishu' | 'telegram' | 'wecom' | 'dingtalk'
|
|
7
7
|
externalId: string // openId / chatId / userId
|
|
8
8
|
text: string
|
|
9
9
|
allowed: Set<string>
|
|
@@ -14,11 +14,11 @@ export interface ChannelMessageOptions {
|
|
|
14
14
|
provider: string
|
|
15
15
|
model: string
|
|
16
16
|
sendText: (externalId: string, text: string) => Promise<void>
|
|
17
|
-
maxLen: number // 飞书 4000 / Telegram 4096 / 企微 2048
|
|
17
|
+
maxLen: number // 飞书 4000 / Telegram 4096 / 企微 2048 / 钉钉 2000
|
|
18
18
|
logPrefix: string // '[feishu]' / '[telegram]' / '[wecom]'
|
|
19
19
|
}
|
|
20
20
|
|
|
21
|
-
/**
|
|
21
|
+
/** 四频道共享的消息处理骨架:白名单→限流→会话→processPrompt→回发。 */
|
|
22
22
|
export async function handleChannelMessage(opts: ChannelMessageOptions): Promise<void> {
|
|
23
23
|
const {
|
|
24
24
|
channel,
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import type { DingtalkApi } from './api.js'
|
|
2
|
+
import type { DingtalkConfig, DingtalkMessage } from './types.js'
|
|
3
|
+
import type { SessionManager } from '../session-manager'
|
|
4
|
+
import type { SessionWorker } from '../session-worker'
|
|
5
|
+
import type { RateLimiter } from '../rate-limiter'
|
|
6
|
+
import { startDingtalkWs } from './ws-client.js'
|
|
7
|
+
import { handleChannelMessage } from '../channel-message.js'
|
|
8
|
+
|
|
9
|
+
export interface DingtalkAdapterDeps {
|
|
10
|
+
sm: SessionManager
|
|
11
|
+
getOrCreateWorker: (sessionId: string) => SessionWorker | null
|
|
12
|
+
rateLimiter: RateLimiter
|
|
13
|
+
cwd: string
|
|
14
|
+
provider: string
|
|
15
|
+
model: string
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface DingtalkAdapter {
|
|
19
|
+
start(): () => void
|
|
20
|
+
handleMessage(msg: DingtalkMessage): Promise<void>
|
|
21
|
+
isAllowed(staffId: string): boolean
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function createDingtalkAdapter(
|
|
25
|
+
config: DingtalkConfig,
|
|
26
|
+
api: DingtalkApi,
|
|
27
|
+
deps: DingtalkAdapterDeps,
|
|
28
|
+
): DingtalkAdapter {
|
|
29
|
+
const allowed = new Set(config.allowedStaffIds)
|
|
30
|
+
let stopWs: (() => void) | null = null
|
|
31
|
+
|
|
32
|
+
async function handleMessage(msg: DingtalkMessage): Promise<void> {
|
|
33
|
+
await handleChannelMessage({
|
|
34
|
+
channel: 'dingtalk',
|
|
35
|
+
externalId: msg.staffId,
|
|
36
|
+
text: msg.text,
|
|
37
|
+
allowed,
|
|
38
|
+
rateLimiter: deps.rateLimiter,
|
|
39
|
+
sm: deps.sm,
|
|
40
|
+
getOrCreateWorker: deps.getOrCreateWorker,
|
|
41
|
+
cwd: deps.cwd,
|
|
42
|
+
provider: deps.provider,
|
|
43
|
+
model: deps.model,
|
|
44
|
+
// 钉钉回发走每条消息自带的 sessionWebhook(非持久 userId 路由)
|
|
45
|
+
sendText: async (_staffId, text) => {
|
|
46
|
+
await api.reply(msg.sessionWebhook, text)
|
|
47
|
+
},
|
|
48
|
+
maxLen: 2000,
|
|
49
|
+
logPrefix: '[dingtalk]',
|
|
50
|
+
})
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
return {
|
|
54
|
+
handleMessage,
|
|
55
|
+
isAllowed: (staffId) => allowed.has(staffId),
|
|
56
|
+
start() {
|
|
57
|
+
if (stopWs) return stopWs
|
|
58
|
+
stopWs = startDingtalkWs(api, handleMessage)
|
|
59
|
+
return stopWs
|
|
60
|
+
},
|
|
61
|
+
}
|
|
62
|
+
}
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
import type { DingtalkConfig, DingtalkMessage } from './types.js'
|
|
2
|
+
|
|
3
|
+
const GATEWAY_URL = 'https://api.dingtalk.com/v1.0/gateway/connections/open'
|
|
4
|
+
|
|
5
|
+
export interface DingtalkApi {
|
|
6
|
+
register(): Promise<{ endpoint: string; ticket: string }>
|
|
7
|
+
open(endpoint: string, ticket: string): WebSocket
|
|
8
|
+
reply(sessionWebhook: string, text: string): Promise<void>
|
|
9
|
+
parseMessage(frame: unknown): DingtalkMessage | null
|
|
10
|
+
isPing(frame: unknown): boolean
|
|
11
|
+
ack(ws: WebSocket, frame: unknown): void
|
|
12
|
+
pong(ws: WebSocket, frame: unknown): void
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/** 回帧信封:code 200 + 回显 headers.messageId。ack 与 pong 共用。 */
|
|
16
|
+
function sendResponse(ws: WebSocket, frame: unknown, data: unknown): void {
|
|
17
|
+
const f = frame as { headers?: { messageId?: string } }
|
|
18
|
+
ws.send(
|
|
19
|
+
JSON.stringify({
|
|
20
|
+
code: 200,
|
|
21
|
+
headers: { contentType: 'application/json', messageId: f?.headers?.messageId ?? '' },
|
|
22
|
+
message: 'OK',
|
|
23
|
+
data,
|
|
24
|
+
}),
|
|
25
|
+
)
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* 钉钉 Stream Mode 协议 codec。零依赖:HTTP 用裸 fetch(register + 回发),
|
|
30
|
+
* WebSocket 用 globalThis.WebSocket(Node 22+ / Bun 原生)。ticket 一次性且
|
|
31
|
+
* 90s 过期,由 ws-client 每次重连前重新 register。
|
|
32
|
+
*/
|
|
33
|
+
export function createDingtalkApi(
|
|
34
|
+
config: DingtalkConfig,
|
|
35
|
+
fetchImpl: typeof fetch = fetch,
|
|
36
|
+
): DingtalkApi {
|
|
37
|
+
return {
|
|
38
|
+
async register() {
|
|
39
|
+
const res = await fetchImpl(GATEWAY_URL, {
|
|
40
|
+
method: 'POST',
|
|
41
|
+
headers: { 'Content-Type': 'application/json' },
|
|
42
|
+
body: JSON.stringify({
|
|
43
|
+
clientId: config.clientId,
|
|
44
|
+
clientSecret: config.clientSecret,
|
|
45
|
+
subscriptions: [{ type: 'CALLBACK', topic: '/v1.0/im/bot/messages/get' }],
|
|
46
|
+
ua: 'mipham-code',
|
|
47
|
+
}),
|
|
48
|
+
})
|
|
49
|
+
if (!res.ok) throw new Error(`dingtalk gateway register ${res.status}`)
|
|
50
|
+
const data = (await res.json()) as { endpoint?: string; ticket?: string }
|
|
51
|
+
if (!data.endpoint || !data.ticket) {
|
|
52
|
+
throw new Error('dingtalk gateway: missing endpoint/ticket')
|
|
53
|
+
}
|
|
54
|
+
return { endpoint: data.endpoint, ticket: data.ticket }
|
|
55
|
+
},
|
|
56
|
+
|
|
57
|
+
open(endpoint, ticket) {
|
|
58
|
+
return new WebSocket(`${endpoint}?ticket=${encodeURIComponent(ticket)}`)
|
|
59
|
+
},
|
|
60
|
+
|
|
61
|
+
async reply(sessionWebhook, text) {
|
|
62
|
+
if (!sessionWebhook) return
|
|
63
|
+
const res = await fetchImpl(sessionWebhook, {
|
|
64
|
+
method: 'POST',
|
|
65
|
+
headers: { 'Content-Type': 'application/json' },
|
|
66
|
+
body: JSON.stringify({ msgtype: 'text', text: { content: text } }),
|
|
67
|
+
})
|
|
68
|
+
if (!res.ok) throw new Error(`dingtalk reply ${res.status}`)
|
|
69
|
+
const data = (await res.json()) as { errcode?: number; errmsg?: string }
|
|
70
|
+
if (data.errcode != null && data.errcode !== 0) {
|
|
71
|
+
throw new Error(`dingtalk reply errcode=${data.errcode}: ${data.errmsg ?? 'unknown'}`)
|
|
72
|
+
}
|
|
73
|
+
},
|
|
74
|
+
|
|
75
|
+
parseMessage(frame) {
|
|
76
|
+
if (!frame || typeof frame !== 'object') return null
|
|
77
|
+
const f = frame as { type?: string; data?: unknown }
|
|
78
|
+
if (f.type !== 'CALLBACK' || typeof f.data !== 'string') return null
|
|
79
|
+
let payload: {
|
|
80
|
+
msgtype?: string
|
|
81
|
+
text?: { content?: string }
|
|
82
|
+
senderStaffId?: string
|
|
83
|
+
senderId?: string
|
|
84
|
+
conversationId?: string
|
|
85
|
+
msgId?: string
|
|
86
|
+
sessionWebhook?: string
|
|
87
|
+
}
|
|
88
|
+
try {
|
|
89
|
+
payload = JSON.parse(f.data)
|
|
90
|
+
} catch {
|
|
91
|
+
return null
|
|
92
|
+
}
|
|
93
|
+
if (payload?.msgtype !== 'text') return null
|
|
94
|
+
const text = payload?.text?.content?.trim()
|
|
95
|
+
if (!text) return null
|
|
96
|
+
const staffId = payload?.senderStaffId ?? payload?.senderId
|
|
97
|
+
if (!staffId) return null
|
|
98
|
+
return {
|
|
99
|
+
staffId,
|
|
100
|
+
conversationId: payload?.conversationId ?? '',
|
|
101
|
+
msgId: payload?.msgId ?? '',
|
|
102
|
+
text,
|
|
103
|
+
sessionWebhook: payload?.sessionWebhook ?? '',
|
|
104
|
+
}
|
|
105
|
+
},
|
|
106
|
+
|
|
107
|
+
isPing(frame) {
|
|
108
|
+
const f = frame as { type?: string; headers?: { topic?: string } }
|
|
109
|
+
return f?.type === 'SYSTEM' && f?.headers?.topic === 'ping'
|
|
110
|
+
},
|
|
111
|
+
|
|
112
|
+
ack(ws, frame) {
|
|
113
|
+
sendResponse(ws, frame, '{"response": null}')
|
|
114
|
+
},
|
|
115
|
+
|
|
116
|
+
pong(ws, frame) {
|
|
117
|
+
sendResponse(ws, frame, (frame as { data?: unknown }).data)
|
|
118
|
+
},
|
|
119
|
+
}
|
|
120
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import type { DingtalkConfig } from './types.js'
|
|
2
|
+
|
|
3
|
+
/** fail-closed:缺 clientId 或 clientSecret → null(daemon 不启用钉钉)。 */
|
|
4
|
+
export function parseDingtalkEnv(): DingtalkConfig | null {
|
|
5
|
+
const clientId = process.env.DINGTALK_CLIENT_ID
|
|
6
|
+
const clientSecret = process.env.DINGTALK_CLIENT_SECRET
|
|
7
|
+
if (!clientId || !clientSecret) return null
|
|
8
|
+
return {
|
|
9
|
+
clientId,
|
|
10
|
+
clientSecret,
|
|
11
|
+
allowedStaffIds: (process.env.DINGTALK_ALLOWED_STAFF_IDS || '')
|
|
12
|
+
.split(',')
|
|
13
|
+
.map((s) => s.trim())
|
|
14
|
+
.filter(Boolean),
|
|
15
|
+
}
|
|
16
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
export interface DingtalkConfig {
|
|
2
|
+
clientId: string
|
|
3
|
+
clientSecret: string
|
|
4
|
+
allowedStaffIds: string[] // 白名单(钉钉 senderStaffId / senderId)
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
export interface DingtalkMessage {
|
|
8
|
+
staffId: string // 发消息用户(senderStaffId,回退 senderId)
|
|
9
|
+
conversationId: string // 会话 id
|
|
10
|
+
msgId: string // 消息 id(业务侧)
|
|
11
|
+
text: string // 文本内容
|
|
12
|
+
sessionWebhook: string // 回发路由(机器人 sessionWebhook URL)
|
|
13
|
+
}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import type { DingtalkApi } from './api.js'
|
|
2
|
+
import type { DingtalkMessage } from './types.js'
|
|
3
|
+
import { nextBackoff } from '../backoff.js'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* 钉钉 Stream 长连接生命周期:register(HTTP 拿 endpoint+ticket)→ 建连 →
|
|
7
|
+
* 服务端 ping/pong → 消息回调(parse→ack→onMessage)→ 断开重连。ticket 一次性
|
|
8
|
+
* 且 90s 过期,故每次重连前必须重新 register。返回 stop。
|
|
9
|
+
*/
|
|
10
|
+
export function startDingtalkWs(
|
|
11
|
+
api: DingtalkApi,
|
|
12
|
+
onMessage: (msg: DingtalkMessage) => Promise<void>,
|
|
13
|
+
): () => void {
|
|
14
|
+
let stopped = false
|
|
15
|
+
let ws: WebSocket | null = null
|
|
16
|
+
let backoffMs = 1000
|
|
17
|
+
let reconnectTimer: ReturnType<typeof setTimeout> | null = null
|
|
18
|
+
|
|
19
|
+
function clearReconnect() {
|
|
20
|
+
if (reconnectTimer) clearTimeout(reconnectTimer)
|
|
21
|
+
reconnectTimer = null
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function scheduleReconnect() {
|
|
25
|
+
if (stopped) return
|
|
26
|
+
reconnectTimer = setTimeout(() => void connect(), backoffMs)
|
|
27
|
+
backoffMs = nextBackoff(backoffMs)
|
|
28
|
+
;(reconnectTimer as unknown as { unref?: () => void }).unref?.()
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
async function connect() {
|
|
32
|
+
if (stopped) return
|
|
33
|
+
let endpoint: string
|
|
34
|
+
let ticket: string
|
|
35
|
+
try {
|
|
36
|
+
;({ endpoint, ticket } = await api.register())
|
|
37
|
+
} catch {
|
|
38
|
+
scheduleReconnect()
|
|
39
|
+
return
|
|
40
|
+
}
|
|
41
|
+
if (stopped) return
|
|
42
|
+
ws = api.open(endpoint, ticket)
|
|
43
|
+
ws.onopen = () => {
|
|
44
|
+
backoffMs = 1000
|
|
45
|
+
}
|
|
46
|
+
ws.onmessage = (ev) => {
|
|
47
|
+
let frame: unknown
|
|
48
|
+
try {
|
|
49
|
+
frame = JSON.parse(ev.data as string)
|
|
50
|
+
} catch {
|
|
51
|
+
return
|
|
52
|
+
}
|
|
53
|
+
if (api.isPing(frame)) {
|
|
54
|
+
if (ws) api.pong(ws, frame)
|
|
55
|
+
return
|
|
56
|
+
}
|
|
57
|
+
const msg = api.parseMessage(frame)
|
|
58
|
+
if (msg) {
|
|
59
|
+
if (ws) api.ack(ws, frame)
|
|
60
|
+
void onMessage(msg).catch(() => {})
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
ws.onclose = () => {
|
|
64
|
+
ws = null
|
|
65
|
+
scheduleReconnect()
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
void connect()
|
|
70
|
+
return () => {
|
|
71
|
+
stopped = true
|
|
72
|
+
clearReconnect()
|
|
73
|
+
if (ws) {
|
|
74
|
+
try {
|
|
75
|
+
ws.close()
|
|
76
|
+
} catch {
|
|
77
|
+
/* 连接尚未建立时 close 可能抛错,忽略 */
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
}
|
package/src/daemon/index.ts
CHANGED
|
@@ -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
|
|
package/src/daemon/server.ts
CHANGED
|
@@ -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/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(
|
|
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(),
|
|
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
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
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)
|
package/src/mcp/transport.ts
CHANGED
|
@@ -19,7 +19,12 @@ export interface Transport {
|
|
|
19
19
|
isConnected(): boolean
|
|
20
20
|
}
|
|
21
21
|
|
|
22
|
-
const
|
|
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(
|
|
173
|
-
},
|
|
178
|
+
reject(requestTimeoutError(method, this.requestTimeoutMs))
|
|
179
|
+
}, this.requestTimeoutMs)
|
|
174
180
|
|
|
175
181
|
this.pending.set(id, { resolve, reject, timer })
|
|
176
182
|
|
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
export const PACKAGE_NAME = '@miphamai/cli' as const
|
|
10
10
|
|
|
11
11
|
/** 当前发布版本 */
|
|
12
|
-
export const PACKAGE_VERSION = '0.
|
|
12
|
+
export const PACKAGE_VERSION = '0.54.0' as const
|
|
13
13
|
|
|
14
14
|
/** npm install 全局安装命令 */
|
|
15
15
|
export const NPM_INSTALL_COMMAND = `npm install -g ${PACKAGE_NAME}` as const
|
package/src/shared/types.ts
CHANGED
|
@@ -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
|
package/src/ui/commands.ts
CHANGED
|
@@ -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
|
-
|
|
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 {
|