feat: 工具层及 mcp 大重构 (#252)

* feat: 第一版大重构

* fix: 修复类型问题

* chore: 更新版本到 1.3.2

* Add brave as alternative WebSearchTool

* fix: 修正顺序

* fix: 修复对穷鬼模式的 auto dream 和 session memory 越过

* feat: 穷鬼模式去除 session-summary

* feat: 创建 builtin-tools 包,搬运所有工具实现

将 src/tools/ 下的全部 60 个工具目录迁移至 packages/builtin-tools/src/tools/,
内部导入路径已更新为 src/ alias 模式。

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* refactor: 更新 src/ 中所有工具引用至 builtin-tools 包,删除 src/tools/

- src/tools.ts 及 178 个 src/ 文件的 import 路径从 ./tools/ 改为 builtin-tools/tools/
- 删除 src/tools/ 整个目录(已迁移至 packages/builtin-tools/)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* chore: 添加 builtin-tools 路径别名至 tsconfig,更新 bun.lock

- tsconfig.json 新增 builtin-tools/* 和 builtin-tools 路径映射
- 新增 packages/builtin-tools/src 至 include

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* refactor: 为 builtin-tools、mcp-client、agent-tools 添加 @claude-code-best 作用域前缀

所有包名及 import 路径统一添加 @claude-code-best/ 前缀:
- builtin-tools → @claude-code-best/builtin-tools
- mcp-client → @claude-code-best/mcp-client
- agent-tools → @claude-code-best/agent-tools

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: 修复 node 环境没有 bun 的问题

---------

Co-authored-by: Eric-Guo <eric.guocz@gmail.com>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
claude-code-best
2026-04-13 09:52:05 +08:00
committed by GitHub
parent bbb8b613a9
commit 2fb1c9dcd8
559 changed files with 9346 additions and 1837 deletions

View File

@@ -0,0 +1,329 @@
import { z } from 'zod/v4'
import {
getOriginalCwd,
getProjectRoot,
setOriginalCwd,
setProjectRoot,
} from 'src/bootstrap/state.js'
import { clearSystemPromptSections } from 'src/constants/systemPromptSections.js'
import { logEvent } from 'src/services/analytics/index.js'
import type { Tool } from 'src/Tool.js'
import { buildTool, type ToolDef } from 'src/Tool.js'
import { count } from 'src/utils/array.js'
import { clearMemoryFileCaches } from 'src/utils/claudemd.js'
import { execFileNoThrow } from 'src/utils/execFileNoThrow.js'
import { updateHooksConfigSnapshot } from 'src/utils/hooks/hooksConfigSnapshot.js'
import { lazySchema } from 'src/utils/lazySchema.js'
import { getPlansDirectory } from 'src/utils/plans.js'
import { setCwd } from 'src/utils/Shell.js'
import { saveWorktreeState } from 'src/utils/sessionStorage.js'
import {
cleanupWorktree,
getCurrentWorktreeSession,
keepWorktree,
killTmuxSession,
} from 'src/utils/worktree.js'
import { EXIT_WORKTREE_TOOL_NAME } from './constants.js'
import { getExitWorktreeToolPrompt } from './prompt.js'
import { renderToolResultMessage, renderToolUseMessage } from './UI.js'
const inputSchema = lazySchema(() =>
z.strictObject({
action: z
.enum(['keep', 'remove'])
.describe(
'"keep" leaves the worktree and branch on disk; "remove" deletes both.',
),
discard_changes: z
.boolean()
.optional()
.describe(
'Required true when action is "remove" and the worktree has uncommitted files or unmerged commits. The tool will refuse and list them otherwise.',
),
}),
)
type InputSchema = ReturnType<typeof inputSchema>
const outputSchema = lazySchema(() =>
z.object({
action: z.enum(['keep', 'remove']),
originalCwd: z.string(),
worktreePath: z.string(),
worktreeBranch: z.string().optional(),
tmuxSessionName: z.string().optional(),
discardedFiles: z.number().optional(),
discardedCommits: z.number().optional(),
message: z.string(),
}),
)
type OutputSchema = ReturnType<typeof outputSchema>
export type Output = z.infer<OutputSchema>
type ChangeSummary = {
changedFiles: number
commits: number
}
/**
* Returns null when state cannot be reliably determined — callers that use
* this as a safety gate must treat null as "unknown, assume unsafe"
* (fail-closed). A silent 0/0 would let cleanupWorktree destroy real work.
*
* Null is returned when:
* - git status or rev-list exit non-zero (lock file, corrupt index, bad ref)
* - originalHeadCommit is undefined but git status succeeded — this is the
* hook-based-worktree-wrapping-git case (worktree.ts:525-532 doesn't set
* originalHeadCommit). We can see the working tree is git, but cannot count
* commits without a baseline, so we cannot prove the branch is clean.
*/
async function countWorktreeChanges(
worktreePath: string,
originalHeadCommit: string | undefined,
): Promise<ChangeSummary | null> {
const status = await execFileNoThrow('git', [
'-C',
worktreePath,
'status',
'--porcelain',
])
if (status.code !== 0) {
return null
}
const changedFiles = count(status.stdout.split('\n'), l => l.trim() !== '')
if (!originalHeadCommit) {
// git status succeeded → this is a git repo, but without a baseline
// commit we cannot count commits. Fail-closed rather than claim 0.
return null
}
const revList = await execFileNoThrow('git', [
'-C',
worktreePath,
'rev-list',
'--count',
`${originalHeadCommit}..HEAD`,
])
if (revList.code !== 0) {
return null
}
const commits = parseInt(revList.stdout.trim(), 10) || 0
return { changedFiles, commits }
}
/**
* Restore session state to reflect the original directory.
* This is the inverse of the session-level mutations in EnterWorktreeTool.call().
*
* keepWorktree()/cleanupWorktree() handle process.chdir and currentWorktreeSession;
* this handles everything above the worktree utility layer.
*/
function restoreSessionToOriginalCwd(
originalCwd: string,
projectRootIsWorktree: boolean,
): void {
setCwd(originalCwd)
// EnterWorktree sets originalCwd to the *worktree* path (intentional — see
// state.ts getProjectRoot comment). Reset to the real original.
setOriginalCwd(originalCwd)
// --worktree startup sets projectRoot to the worktree; mid-session
// EnterWorktreeTool does not. Only restore when it was actually changed —
// otherwise we'd move projectRoot to wherever the user had cd'd before
// entering the worktree (session.originalCwd), breaking the "stable project
// identity" contract.
if (projectRootIsWorktree) {
setProjectRoot(originalCwd)
// setup.ts's --worktree block called updateHooksConfigSnapshot() to re-read
// hooks from the worktree. Restore symmetrically. (Mid-session
// EnterWorktreeTool never touched the snapshot, so no-op there.)
updateHooksConfigSnapshot()
}
saveWorktreeState(null)
clearSystemPromptSections()
clearMemoryFileCaches()
getPlansDirectory.cache.clear?.()
}
export const ExitWorktreeTool: Tool<InputSchema, Output> = buildTool({
name: EXIT_WORKTREE_TOOL_NAME,
searchHint: 'exit a worktree session and return to the original directory',
maxResultSizeChars: 100_000,
async description() {
return 'Exits a worktree session created by EnterWorktree and restores the original working directory'
},
async prompt() {
return getExitWorktreeToolPrompt()
},
get inputSchema(): InputSchema {
return inputSchema()
},
get outputSchema(): OutputSchema {
return outputSchema()
},
userFacingName() {
return 'Exiting worktree'
},
shouldDefer: true,
isDestructive(input) {
return input.action === 'remove'
},
toAutoClassifierInput(input) {
return input.action
},
async validateInput(input) {
// Scope guard: getCurrentWorktreeSession() is null unless EnterWorktree
// (specifically createWorktreeForSession) ran in THIS session. Worktrees
// created by `git worktree add`, or by EnterWorktree in a previous
// session, do not populate it. This is the sole entry gate — everything
// past this point operates on a path EnterWorktree created.
const session = getCurrentWorktreeSession()
if (!session) {
return {
result: false,
message:
'No-op: there is no active EnterWorktree session to exit. This tool only operates on worktrees created by EnterWorktree in the current session — it will not touch worktrees created manually or in a previous session. No filesystem changes were made.',
errorCode: 1,
}
}
if (input.action === 'remove' && !input.discard_changes) {
const summary = await countWorktreeChanges(
session.worktreePath,
session.originalHeadCommit,
)
if (summary === null) {
return {
result: false,
message: `Could not verify worktree state at ${session.worktreePath}. Refusing to remove without explicit confirmation. Re-invoke with discard_changes: true to proceed — or use action: "keep" to preserve the worktree.`,
errorCode: 3,
}
}
const { changedFiles, commits } = summary
if (changedFiles > 0 || commits > 0) {
const parts: string[] = []
if (changedFiles > 0) {
parts.push(
`${changedFiles} uncommitted ${changedFiles === 1 ? 'file' : 'files'}`,
)
}
if (commits > 0) {
parts.push(
`${commits} ${commits === 1 ? 'commit' : 'commits'} on ${session.worktreeBranch ?? 'the worktree branch'}`,
)
}
return {
result: false,
message: `Worktree has ${parts.join(' and ')}. Removing will discard this work permanently. Confirm with the user, then re-invoke with discard_changes: true — or use action: "keep" to preserve the worktree.`,
errorCode: 2,
}
}
}
return { result: true }
},
renderToolUseMessage,
renderToolResultMessage,
async call(input) {
const session = getCurrentWorktreeSession()
if (!session) {
// validateInput guards this, but the session is module-level mutable
// state — defend against a race between validation and execution.
throw new Error('Not in a worktree session')
}
// Capture before keepWorktree/cleanupWorktree null out currentWorktreeSession.
const {
originalCwd,
worktreePath,
worktreeBranch,
tmuxSessionName,
originalHeadCommit,
} = session
// --worktree startup calls setOriginalCwd(getCwd()) and
// setProjectRoot(getCwd()) back-to-back right after setCwd(worktreePath)
// (setup.ts:235/239), so both hold the same realpath'd value and BashTool
// cd never touches either. Mid-session EnterWorktreeTool sets originalCwd
// but NOT projectRoot. (Can't use getCwd() — BashTool mutates it on every
// cd. Can't use session.worktreePath — it's join()'d, not realpath'd.)
const projectRootIsWorktree = getProjectRoot() === getOriginalCwd()
// Re-count at execution time for accurate analytics and output — the
// worktree state at validateInput time may not match now. Null (git
// failure) falls back to 0/0; safety gating already happened in
// validateInput, so this only affects analytics + messaging.
const { changedFiles, commits } = (await countWorktreeChanges(
worktreePath,
originalHeadCommit,
)) ?? { changedFiles: 0, commits: 0 }
if (input.action === 'keep') {
await keepWorktree()
restoreSessionToOriginalCwd(originalCwd, projectRootIsWorktree)
logEvent('tengu_worktree_kept', {
mid_session: true,
commits,
changed_files: changedFiles,
})
const tmuxNote = tmuxSessionName
? ` Tmux session ${tmuxSessionName} is still running; reattach with: tmux attach -t ${tmuxSessionName}`
: ''
return {
data: {
action: 'keep' as const,
originalCwd,
worktreePath,
worktreeBranch,
tmuxSessionName,
message: `Exited worktree. Your work is preserved at ${worktreePath}${worktreeBranch ? ` on branch ${worktreeBranch}` : ''}. Session is now back in ${originalCwd}.${tmuxNote}`,
},
}
}
// action === 'remove'
if (tmuxSessionName) {
await killTmuxSession(tmuxSessionName)
}
await cleanupWorktree()
restoreSessionToOriginalCwd(originalCwd, projectRootIsWorktree)
logEvent('tengu_worktree_removed', {
mid_session: true,
commits,
changed_files: changedFiles,
})
const discardParts: string[] = []
if (commits > 0) {
discardParts.push(`${commits} ${commits === 1 ? 'commit' : 'commits'}`)
}
if (changedFiles > 0) {
discardParts.push(
`${changedFiles} uncommitted ${changedFiles === 1 ? 'file' : 'files'}`,
)
}
const discardNote =
discardParts.length > 0 ? ` Discarded ${discardParts.join(' and ')}.` : ''
return {
data: {
action: 'remove' as const,
originalCwd,
worktreePath,
worktreeBranch,
discardedFiles: changedFiles,
discardedCommits: commits,
message: `Exited and removed worktree at ${worktreePath}.${discardNote} Session is now back in ${originalCwd}.`,
},
}
},
mapToolResultToToolResultBlockParam({ message }, toolUseID) {
return {
type: 'tool_result',
content: message,
tool_use_id: toolUseID,
}
},
} satisfies ToolDef<InputSchema, Output>)

View File

@@ -0,0 +1,33 @@
import * as React from 'react'
import { Box, Text } from '@anthropic/ink'
import type { ToolProgressData } from 'src/Tool.js'
import type { ProgressMessage } from 'src/types/message.js'
import type { ThemeName } from 'src/utils/theme.js'
import type { Output } from './ExitWorktreeTool.js'
export function renderToolUseMessage(): React.ReactNode {
return 'Exiting worktree…'
}
export function renderToolResultMessage(
output: Output,
_progressMessagesForMessage: ProgressMessage<ToolProgressData>[],
_options: { theme: ThemeName },
): React.ReactNode {
const actionLabel =
output.action === 'keep' ? 'Kept worktree' : 'Removed worktree'
return (
<Box flexDirection="column">
<Text>
{actionLabel}
{output.worktreeBranch ? (
<>
{' '}
(branch <Text bold>{output.worktreeBranch}</Text>)
</>
) : null}
</Text>
<Text dimColor>Returned to {output.originalCwd}</Text>
</Box>
)
}

View File

@@ -0,0 +1 @@
export const EXIT_WORKTREE_TOOL_NAME = 'ExitWorktree'

View File

@@ -0,0 +1,32 @@
export function getExitWorktreeToolPrompt(): string {
return `Exit a worktree session created by EnterWorktree and return the session to the original working directory.
## Scope
This tool ONLY operates on worktrees created by EnterWorktree in this session. It will NOT touch:
- Worktrees you created manually with \`git worktree add\`
- Worktrees from a previous session (even if created by EnterWorktree then)
- The directory you're in if EnterWorktree was never called
If called outside an EnterWorktree session, the tool is a **no-op**: it reports that no worktree session is active and takes no action. Filesystem state is unchanged.
## When to Use
- The user explicitly asks to "exit the worktree", "leave the worktree", "go back", or otherwise end the worktree session
- Do NOT call this proactively — only when the user asks
## Parameters
- \`action\` (required): \`"keep"\` or \`"remove"\`
- \`"keep"\` — leave the worktree directory and branch intact on disk. Use this if the user wants to come back to the work later, or if there are changes to preserve.
- \`"remove"\` — delete the worktree directory and its branch. Use this for a clean exit when the work is done or abandoned.
- \`discard_changes\` (optional, default false): only meaningful with \`action: "remove"\`. If the worktree has uncommitted files or commits not on the original branch, the tool will REFUSE to remove it unless this is set to \`true\`. If the tool returns an error listing changes, confirm with the user before re-invoking with \`discard_changes: true\`.
## Behavior
- Restores the session's working directory to where it was before EnterWorktree
- Clears CWD-dependent caches (system prompt sections, memory files, plans directory) so the session state reflects the original directory
- If a tmux session was attached to the worktree: killed on \`remove\`, left running on \`keep\` (its name is returned so the user can reattach)
- Once exited, EnterWorktree can be called again to create a fresh worktree
`
}