mirror of
https://github.com/claude-code-best/claude-code.git
synced 2026-06-17 05:45:51 +00:00
* 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>
343 lines
11 KiB
TypeScript
343 lines
11 KiB
TypeScript
import { feature } from 'bun:bundle'
|
|
import * as React from 'react'
|
|
import {
|
|
getAllowedChannels,
|
|
getQuestionPreviewFormat,
|
|
} from 'src/bootstrap/state.js'
|
|
import { MessageResponse } from 'src/components/MessageResponse.js'
|
|
import { BLACK_CIRCLE } from 'src/constants/figures.js'
|
|
import { getModeColor } from 'src/utils/permissions/PermissionMode.js'
|
|
import { z } from 'zod/v4'
|
|
import { Box, Text } from '@anthropic/ink'
|
|
import type { Tool } from 'src/Tool.js'
|
|
import { buildTool, type ToolDef } from 'src/Tool.js'
|
|
import { lazySchema } from 'src/utils/lazySchema.js'
|
|
import {
|
|
ASK_USER_QUESTION_TOOL_CHIP_WIDTH,
|
|
ASK_USER_QUESTION_TOOL_NAME,
|
|
ASK_USER_QUESTION_TOOL_PROMPT,
|
|
DESCRIPTION,
|
|
PREVIEW_FEATURE_PROMPT,
|
|
} from './prompt.js'
|
|
|
|
const questionOptionSchema = lazySchema(() =>
|
|
z.object({
|
|
label: z
|
|
.string()
|
|
.describe(
|
|
'The display text for this option that the user will see and select. Should be concise (1-5 words) and clearly describe the choice.',
|
|
),
|
|
description: z
|
|
.string()
|
|
.describe(
|
|
'Explanation of what this option means or what will happen if chosen. Useful for providing context about trade-offs or implications.',
|
|
),
|
|
preview: z
|
|
.string()
|
|
.optional()
|
|
.describe(
|
|
'Optional preview content rendered when this option is focused. Use for mockups, code snippets, or visual comparisons that help users compare options. See the tool description for the expected content format.',
|
|
),
|
|
}),
|
|
)
|
|
|
|
const questionSchema = lazySchema(() =>
|
|
z.object({
|
|
question: z
|
|
.string()
|
|
.describe(
|
|
'The complete question to ask the user. Should be clear, specific, and end with a question mark. Example: "Which library should we use for date formatting?" If multiSelect is true, phrase it accordingly, e.g. "Which features do you want to enable?"',
|
|
),
|
|
header: z
|
|
.string()
|
|
.describe(
|
|
`Very short label displayed as a chip/tag (max ${ASK_USER_QUESTION_TOOL_CHIP_WIDTH} chars). Examples: "Auth method", "Library", "Approach".`,
|
|
),
|
|
options: z
|
|
.array(questionOptionSchema())
|
|
.min(2)
|
|
.max(4)
|
|
.describe(
|
|
`The available choices for this question. Must have 2-4 options. Each option should be a distinct, mutually exclusive choice (unless multiSelect is enabled). There should be no 'Other' option, that will be provided automatically.`,
|
|
),
|
|
multiSelect: z
|
|
.boolean()
|
|
.default(false)
|
|
.describe(
|
|
'Set to true to allow the user to select multiple options instead of just one. Use when choices are not mutually exclusive.',
|
|
),
|
|
}),
|
|
)
|
|
|
|
const annotationsSchema = lazySchema(() => {
|
|
const annotationSchema = z.object({
|
|
preview: z
|
|
.string()
|
|
.optional()
|
|
.describe(
|
|
'The preview content of the selected option, if the question used previews.',
|
|
),
|
|
notes: z
|
|
.string()
|
|
.optional()
|
|
.describe('Free-text notes the user added to their selection.'),
|
|
})
|
|
|
|
return z
|
|
.record(z.string(), annotationSchema)
|
|
.optional()
|
|
.describe(
|
|
'Optional per-question annotations from the user (e.g., notes on preview selections). Keyed by question text.',
|
|
)
|
|
})
|
|
|
|
const UNIQUENESS_REFINE = {
|
|
check: (data: {
|
|
questions: { question: string; options: { label: string }[] }[]
|
|
}) => {
|
|
const questions = data.questions.map(q => q.question)
|
|
if (questions.length !== new Set(questions).size) {
|
|
return false
|
|
}
|
|
for (const question of data.questions) {
|
|
const labels = question.options.map(opt => opt.label)
|
|
if (labels.length !== new Set(labels).size) {
|
|
return false
|
|
}
|
|
}
|
|
return true
|
|
},
|
|
message:
|
|
'Question texts must be unique, option labels must be unique within each question',
|
|
} as const
|
|
|
|
const commonFields = lazySchema(() => ({
|
|
answers: z
|
|
.record(z.string(), z.string())
|
|
.optional()
|
|
.describe('User answers collected by the permission component'),
|
|
annotations: annotationsSchema(),
|
|
metadata: z
|
|
.object({
|
|
source: z
|
|
.string()
|
|
.optional()
|
|
.describe(
|
|
'Optional identifier for the source of this question (e.g., "remember" for /remember command). Used for analytics tracking.',
|
|
),
|
|
})
|
|
.optional()
|
|
.describe(
|
|
'Optional metadata for tracking and analytics purposes. Not displayed to user.',
|
|
),
|
|
}))
|
|
|
|
const inputSchema = lazySchema(() =>
|
|
z
|
|
.strictObject({
|
|
questions: z
|
|
.array(questionSchema())
|
|
.min(1)
|
|
.max(4)
|
|
.describe('Questions to ask the user (1-4 questions)'),
|
|
...commonFields(),
|
|
})
|
|
.refine(UNIQUENESS_REFINE.check, {
|
|
message: UNIQUENESS_REFINE.message,
|
|
}),
|
|
)
|
|
type InputSchema = ReturnType<typeof inputSchema>
|
|
|
|
const outputSchema = lazySchema(() =>
|
|
z.object({
|
|
questions: z
|
|
.array(questionSchema())
|
|
.describe('The questions that were asked'),
|
|
answers: z
|
|
.record(z.string(), z.string())
|
|
.describe(
|
|
'The answers provided by the user (question text -> answer string; multi-select answers are comma-separated)',
|
|
),
|
|
annotations: annotationsSchema(),
|
|
}),
|
|
)
|
|
type OutputSchema = ReturnType<typeof outputSchema>
|
|
|
|
// SDK schemas are identical to internal schemas now that `preview` and
|
|
// `annotations` are public (configurable via `toolConfig.askUserQuestion`).
|
|
export const _sdkInputSchema = inputSchema
|
|
export const _sdkOutputSchema = outputSchema
|
|
|
|
export type Question = z.infer<ReturnType<typeof questionSchema>>
|
|
export type QuestionOption = z.infer<ReturnType<typeof questionOptionSchema>>
|
|
export type Output = z.infer<OutputSchema>
|
|
|
|
function AskUserQuestionResultMessage({
|
|
answers,
|
|
}: {
|
|
answers: Output['answers']
|
|
}): React.ReactNode {
|
|
return (
|
|
<Box flexDirection="column" marginTop={1}>
|
|
<Box flexDirection="row">
|
|
<Text color={getModeColor('default')}>{BLACK_CIRCLE} </Text>
|
|
<Text>User answered Claude's questions:</Text>
|
|
</Box>
|
|
<MessageResponse>
|
|
<Box flexDirection="column">
|
|
{Object.entries(answers).map(([questionText, answer]) => (
|
|
<Text key={questionText} color="inactive">
|
|
· {questionText} → {answer}
|
|
</Text>
|
|
))}
|
|
</Box>
|
|
</MessageResponse>
|
|
</Box>
|
|
)
|
|
}
|
|
|
|
export const AskUserQuestionTool: Tool<InputSchema, Output> = buildTool({
|
|
name: ASK_USER_QUESTION_TOOL_NAME,
|
|
searchHint: 'prompt the user with a multiple-choice question',
|
|
maxResultSizeChars: 100_000,
|
|
shouldDefer: true,
|
|
async description() {
|
|
return DESCRIPTION
|
|
},
|
|
async prompt() {
|
|
const format = getQuestionPreviewFormat()
|
|
if (format === undefined) {
|
|
// SDK consumer that hasn't opted into a preview format — omit preview
|
|
// guidance (they may not render the field at all).
|
|
return ASK_USER_QUESTION_TOOL_PROMPT
|
|
}
|
|
return ASK_USER_QUESTION_TOOL_PROMPT + PREVIEW_FEATURE_PROMPT[format]
|
|
},
|
|
get inputSchema(): InputSchema {
|
|
return inputSchema()
|
|
},
|
|
get outputSchema(): OutputSchema {
|
|
return outputSchema()
|
|
},
|
|
userFacingName() {
|
|
return ''
|
|
},
|
|
isEnabled() {
|
|
// When --channels is active the user is likely on Telegram/Discord, not
|
|
// watching the TUI. The multiple-choice dialog would hang with nobody at
|
|
// the keyboard. Channel permission relay already skips
|
|
// requiresUserInteraction() tools (interactiveHandler.ts) so there's
|
|
// no alternate approval path.
|
|
if (
|
|
(feature('KAIROS') || feature('KAIROS_CHANNELS')) &&
|
|
getAllowedChannels().length > 0
|
|
) {
|
|
return false
|
|
}
|
|
return true
|
|
},
|
|
isConcurrencySafe() {
|
|
return true
|
|
},
|
|
isReadOnly() {
|
|
return true
|
|
},
|
|
toAutoClassifierInput(input) {
|
|
return input.questions.map(q => q.question).join(' | ')
|
|
},
|
|
requiresUserInteraction() {
|
|
return true
|
|
},
|
|
async validateInput({ questions }) {
|
|
if (getQuestionPreviewFormat() !== 'html') {
|
|
return { result: true }
|
|
}
|
|
for (const q of questions) {
|
|
for (const opt of q.options) {
|
|
const err = validateHtmlPreview(opt.preview)
|
|
if (err) {
|
|
return {
|
|
result: false,
|
|
message: `Option "${opt.label}" in question "${q.question}": ${err}`,
|
|
errorCode: 1,
|
|
}
|
|
}
|
|
}
|
|
}
|
|
return { result: true }
|
|
},
|
|
async checkPermissions(input) {
|
|
return {
|
|
behavior: 'ask' as const,
|
|
message: 'Answer questions?',
|
|
updatedInput: input,
|
|
}
|
|
},
|
|
renderToolUseMessage() {
|
|
return null
|
|
},
|
|
renderToolUseProgressMessage() {
|
|
return null
|
|
},
|
|
renderToolResultMessage({ answers }, _toolUseID) {
|
|
return <AskUserQuestionResultMessage answers={answers} />
|
|
},
|
|
renderToolUseRejectedMessage() {
|
|
return (
|
|
<Box flexDirection="row" marginTop={1}>
|
|
<Text color={getModeColor('default')}>{BLACK_CIRCLE} </Text>
|
|
<Text>User declined to answer questions</Text>
|
|
</Box>
|
|
)
|
|
},
|
|
renderToolUseErrorMessage() {
|
|
return null
|
|
},
|
|
async call({ questions, answers = {}, annotations }, _context) {
|
|
return {
|
|
data: { questions, answers, ...(annotations && { annotations }) },
|
|
}
|
|
},
|
|
mapToolResultToToolResultBlockParam({ answers, annotations }, toolUseID) {
|
|
const answersText = Object.entries(answers)
|
|
.map(([questionText, answer]) => {
|
|
const annotation = annotations?.[questionText]
|
|
const parts = [`"${questionText}"="${answer}"`]
|
|
if (annotation?.preview) {
|
|
parts.push(`selected preview:\n${annotation.preview}`)
|
|
}
|
|
if (annotation?.notes) {
|
|
parts.push(`user notes: ${annotation.notes}`)
|
|
}
|
|
return parts.join(' ')
|
|
})
|
|
.join(', ')
|
|
|
|
return {
|
|
type: 'tool_result',
|
|
content: `User has answered your questions: ${answersText}. You can now continue with the user's answers in mind.`,
|
|
tool_use_id: toolUseID,
|
|
}
|
|
},
|
|
} satisfies ToolDef<InputSchema, Output>)
|
|
|
|
// Lightweight HTML fragment check. Not a parser — HTML5 parsers are
|
|
// error-recovering by spec and accept anything. We're checking model intent
|
|
// (did it emit HTML?) and catching the specific things we told it not to do.
|
|
function validateHtmlPreview(preview: string | undefined): string | null {
|
|
if (preview === undefined) return null
|
|
if (/<\s*(html|body|!doctype)\b/i.test(preview)) {
|
|
return 'preview must be an HTML fragment, not a full document (no <html>, <body>, or <!DOCTYPE>)'
|
|
}
|
|
// SDK consumers typically set this via innerHTML — disallow executable/style
|
|
// tags so a preview can't run code or restyle the host page. Inline event
|
|
// handlers (onclick etc.) are still possible; consumers should sanitize.
|
|
if (/<\s*(script|style)\b/i.test(preview)) {
|
|
return 'preview must not contain <script> or <style> tags. Use inline styles via the style attribute if needed.'
|
|
}
|
|
if (!/<[a-z][^>]*>/i.test(preview)) {
|
|
return 'preview must contain HTML (previewFormat is set to "html"). Wrap content in a tag like <div> or <pre>.'
|
|
}
|
|
return null
|
|
}
|