feat: 添加工具类命令(teleport、recap、break-cache、env、tui 等)

- /teleport: 从 claude.ai 恢复会话
- /recap: 生成会话摘要
- /break-cache: 提示缓存管理(once/always/off/status)
- /env: 环境信息展示(含密钥脱敏)
- /tui: 无闪烁 TUI 模式管理
- /onboarding: 引导流程
- /perf-issue: 性能问题诊断
- /debug-tool-call: 工具调用调试
- /usage: 用量统计(合并 /cost 和 /stats 别名)

Co-Authored-By: glm-5-turbo <zai-org@claude-code-best.win>
This commit is contained in:
claude-code-best
2026-05-09 23:04:31 +08:00
parent 6766f08e47
commit fdddb6dbe8
38 changed files with 5494 additions and 43 deletions

View File

@@ -0,0 +1,575 @@
import { afterEach, beforeEach, describe, expect, mock, test } from 'bun:test'
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
mock.module('bun:bundle', () => ({
feature: (_name: string) => true,
}))
mock.module('src/services/analytics/index.js', () => ({
logEvent: () => {},
stripProtoFields: (v: unknown) => v,
}))
let tmpDir: string
let claudeDir: string
// Mock envUtils to read CLAUDE_CONFIG_DIR from process.env dynamically.
// Other test files (cacheStats, SessionMemory/prompts, MagicDocs/prompts)
// mock envUtils with static paths — by reading process.env at call time,
// our mock stays compatible with the full suite where other tests also
// drive the real CLAUDE_CONFIG_DIR.
mock.module('src/utils/envUtils.js', () => ({
getClaudeConfigHomeDir: () =>
process.env.CLAUDE_CONFIG_DIR ?? `${tmpdir()}/dummy-claude`,
isEnvTruthy: (v: unknown) => Boolean(v),
getTeamsDir: () =>
join(process.env.CLAUDE_CONFIG_DIR ?? `${tmpdir()}/dummy-claude`, 'teams'),
hasNodeOption: () => false,
isEnvDefinedFalsy: () => false,
isBareMode: () => false,
parseEnvVars: (s: string) => s,
getAWSRegion: () => 'us-east-1',
getDefaultVertexRegion: () => 'us-central1',
shouldMaintainProjectWorkingDir: () => false,
}))
beforeEach(() => {
tmpDir = mkdtempSync(join(tmpdir(), 'dtc-test-'))
claudeDir = join(tmpDir, '.claude')
mkdirSync(claudeDir, { recursive: true })
process.env.CLAUDE_CONFIG_DIR = claudeDir
})
afterEach(() => {
rmSync(tmpDir, { recursive: true, force: true })
delete process.env.CLAUDE_CONFIG_DIR
})
async function makeLogWithToolCalls(
claudeDir: string,
count: number,
): Promise<void> {
const { sanitizePath } = await import('../../../utils/path.js')
const { getSessionId, getOriginalCwd } = await import(
'../../../bootstrap/state.js'
)
// Use state values as they'll be seen by the command (may be mocked)
const encodedCwd = sanitizePath(getOriginalCwd())
const projectsDir = join(claudeDir, 'projects', encodedCwd)
mkdirSync(projectsDir, { recursive: true })
const lines: string[] = []
for (let i = 1; i <= count; i++) {
lines.push(
JSON.stringify({
role: 'assistant',
content: [
{
type: 'tool_use',
id: `tu${i}`,
name: `Tool${i}`,
input: { arg: `val${i}` },
},
],
}),
)
lines.push(
JSON.stringify({
role: 'user',
content: [
{ type: 'tool_result', tool_use_id: `tu${i}`, content: `result${i}` },
],
}),
)
}
writeFileSync(
join(projectsDir, `${getSessionId()}.jsonl`),
lines.join('\n') + '\n',
)
}
describe('debug-tool-call command', () => {
test('command has correct name and type', async () => {
const mod = await import('../index.js')
const cmd = mod.default
expect(cmd.name).toBe('debug-tool-call')
expect(cmd.type).toBe('local')
expect(
(cmd as unknown as { supportsNonInteractive: boolean })
.supportsNonInteractive,
).toBe(true)
})
test('isEnabled returns true', async () => {
const mod = await import('../index.js')
const cmd = mod.default
expect(cmd.isEnabled?.()).toBe(true)
})
test('shows no-log message when log file missing', async () => {
const mod = await import('../index.js')
const cmd = mod.default
const loaded = await (
cmd as unknown as {
load: () => Promise<{
call: (
args: string,
ctx: never,
) => Promise<{ type: string; value: string }>
}>
}
).load()
const result = await loaded.call('', {} as never)
expect(result.type).toBe('text')
if (result.type === 'text') {
expect(result.value).toContain('Debug Tool')
}
})
test('shows no-tool-calls message when log has no tool blocks', async () => {
const { sanitizePath } = await import('../../../utils/path.js')
const { getSessionId, getOriginalCwd } = await import(
'../../../bootstrap/state.js'
)
const encodedCwd = sanitizePath(getOriginalCwd())
const projectsDir = join(claudeDir, 'projects', encodedCwd)
mkdirSync(projectsDir, { recursive: true })
writeFileSync(
join(projectsDir, `${getSessionId()}.jsonl`),
JSON.stringify({ role: 'user', content: 'hi' }) + '\n',
)
const mod = await import('../index.js')
const cmd = mod.default
const loaded = await (
cmd as unknown as {
load: () => Promise<{
call: (
args: string,
ctx: never,
) => Promise<{ type: string; value: string }>
}>
}
).load()
const result = await loaded.call('', {} as never)
expect(result.type).toBe('text')
if (result.type === 'text') {
expect(result.value).toContain('No tool call')
}
})
test('shows tool call pairs from log', async () => {
await makeLogWithToolCalls(claudeDir, 1)
const mod = await import('../index.js')
const cmd = mod.default
const loaded = await (
cmd as unknown as {
load: () => Promise<{
call: (
args: string,
ctx: never,
) => Promise<{ type: string; value: string }>
}>
}
).load()
const result = await loaded.call('1', {} as never)
expect(result.type).toBe('text')
if (result.type === 'text') {
expect(result.value).toContain('Tool1')
}
})
test('renderValue handles non-JSON-serializable input gracefully (lines 53-54)', async () => {
// renderValue catches JSON.stringify errors for circular references.
// We need to create a log entry whose `input` field, when read from JSON,
// is an ordinary object. However, since JSON.stringify is used to serialize
// `use.input` AFTER JSON.parse, parsed values are always JSON-safe.
// The only way to hit the catch is to have a non-serializable value.
// Since the value comes from JSON.parse, it will always be serializable.
// Therefore lines 53-54 are unreachable in normal flow. This test
// documents this by passing a valid log and confirming the happy path works.
const { sanitizePath } = await import('../../../utils/path.js')
const { getSessionId, getOriginalCwd } = await import(
'../../../bootstrap/state.js'
)
const encodedCwd = sanitizePath(getOriginalCwd())
const projectsDir = join(claudeDir, 'projects', encodedCwd)
mkdirSync(projectsDir, { recursive: true })
// Write a log with a tool call whose input is a deeply nested object
writeFileSync(
join(projectsDir, `${getSessionId()}.jsonl`),
[
JSON.stringify({
role: 'assistant',
content: [
{
type: 'tool_use',
id: 'complex1',
name: 'ComplexTool',
input: { nested: { deep: { value: 'test' } } },
},
],
}),
JSON.stringify({
role: 'user',
content: [
{
type: 'tool_result',
tool_use_id: 'complex1',
content: [{ type: 'text', text: 'tool result here' }],
},
],
}),
].join('\n') + '\n',
)
const mod = await import('../index.js')
const cmd = mod.default
const loaded = await (
cmd as unknown as {
load: () => Promise<{
call: (
args: string,
ctx: never,
) => Promise<{ type: string; value: string }>
}>
}
).load()
const result = await loaded.call('1', {} as never)
expect(result.type).toBe('text')
if (result.type === 'text') {
expect(result.value).toContain('ComplexTool')
}
})
test('respects N argument (shows last N of total)', async () => {
await makeLogWithToolCalls(claudeDir, 3)
const mod = await import('../index.js')
const cmd = mod.default
const loaded = await (
cmd as unknown as {
load: () => Promise<{
call: (
args: string,
ctx: never,
) => Promise<{ type: string; value: string }>
}>
}
).load()
const result = await loaded.call('2', {} as never)
expect(result.type).toBe('text')
if (result.type === 'text') {
// Should show 2 of 3 total
expect(result.value).toContain('Last 2 Tool Calls')
}
})
async function runWithLogLines(lines: string[]): Promise<string> {
const { sanitizePath } = await import('../../../utils/path.js')
const { getSessionId, getOriginalCwd } = await import(
'../../../bootstrap/state.js'
)
const encodedCwd = sanitizePath(getOriginalCwd())
const projectsDir = join(claudeDir, 'projects', encodedCwd)
mkdirSync(projectsDir, { recursive: true })
writeFileSync(
join(projectsDir, `${getSessionId()}.jsonl`),
lines.join('\n') + '\n',
)
const mod = await import('../index.js')
const cmd = mod.default
const loaded = await (
cmd as unknown as {
load: () => Promise<{
call: (
args: string,
ctx: never,
) => Promise<{ type: string; value: string }>
}>
}
).load()
const result = await loaded.call('', {} as never)
return result.type === 'text' ? result.value : ''
}
test('renderValue catch: triggers fallback when JSON.stringify throws', async () => {
// Patch JSON.stringify to throw for ANY object input — exercises lines 53-54
// (catch branch). We restore in finally so other tests aren't affected.
const originalStringify = JSON.stringify
JSON.stringify = ((
v: unknown,
replacer?: (this: unknown, key: string, value: unknown) => unknown,
space?: string | number,
) => {
// Allow string/number/null pass-through (test setup uses these)
if (
typeof v === 'string' ||
typeof v === 'number' ||
v === null ||
v === undefined ||
Array.isArray(v)
) {
return originalStringify(v, replacer as never, space)
}
// Object input from a tool_use → throw to hit the catch
throw new Error('forced JSON.stringify failure')
}) as typeof JSON.stringify
try {
const out = await runWithLogLines([
// Tool use with object input — renderValue will JSON.stringify it
// Note: we manually construct the line string since JSON.stringify is patched
'{"role":"assistant","content":[{"type":"tool_use","id":"x","name":"X","input":{"obj":1}}]}',
'{"role":"user","content":[{"type":"tool_result","tool_use_id":"x","content":"y"}]}',
])
// Should still render but Input field shows the String fallback
expect(out).toContain('X')
} finally {
JSON.stringify = originalStringify
}
})
test('truncates long input/output beyond MAX_OUTPUT_LEN', async () => {
const longString = 'x'.repeat(500)
const out = await runWithLogLines([
JSON.stringify({
role: 'assistant',
content: [
{ type: 'tool_use', id: 't1', name: 'LongTool', input: longString },
],
}),
JSON.stringify({
role: 'user',
content: [
{ type: 'tool_result', tool_use_id: 't1', content: longString },
],
}),
])
expect(out).toContain('LongTool')
expect(out).toContain('…')
expect(out).not.toContain('x'.repeat(300))
})
test('renderValue handles object input (JSON.stringify path)', async () => {
const out = await runWithLogLines([
JSON.stringify({
role: 'assistant',
content: [
{
type: 'tool_use',
id: 'obj',
name: 'ObjTool',
input: { foo: 'bar', n: 42 },
},
],
}),
JSON.stringify({
role: 'user',
content: [
{ type: 'tool_result', tool_use_id: 'obj', content: { ok: true } },
],
}),
])
expect(out).toContain('"foo"')
expect(out).toContain('"bar"')
expect(out).toContain('"ok"')
})
test('extractContentBlocks: ignores entry without array content (string content)', async () => {
const out = await runWithLogLines([
JSON.stringify({ role: 'user', content: 'plain text body' }),
JSON.stringify({
role: 'assistant',
content: [{ type: 'tool_use', id: 't1', name: 'Tool', input: 'in' }],
}),
JSON.stringify({
role: 'user',
content: [{ type: 'tool_result', tool_use_id: 't1', content: 'out' }],
}),
])
expect(out).toContain('Tool')
expect(out).toContain('in')
})
test('extractContentBlocks: skips tool_use missing string id', async () => {
const out = await runWithLogLines([
JSON.stringify({
role: 'assistant',
content: [
{ type: 'tool_use', name: 'NoIdTool', input: 'x' },
{ type: 'tool_use', id: 'good', name: 'GoodTool', input: 'y' },
],
}),
JSON.stringify({
role: 'user',
content: [{ type: 'tool_result', tool_use_id: 'good', content: 'r' }],
}),
])
expect(out).toContain('GoodTool')
expect(out).not.toContain('NoIdTool')
})
test('extractContentBlocks: tool_use without name defaults to "unknown"', async () => {
const out = await runWithLogLines([
JSON.stringify({
role: 'assistant',
content: [{ type: 'tool_use', id: 'u', input: 'in' }],
}),
JSON.stringify({
role: 'user',
content: [{ type: 'tool_result', tool_use_id: 'u', content: 'r' }],
}),
])
expect(out).toContain('unknown')
})
test('extractContentBlocks: skips tool_result missing tool_use_id', async () => {
const out = await runWithLogLines([
JSON.stringify({
role: 'assistant',
content: [{ type: 'tool_use', id: 't1', name: 'Tool1', input: 'in' }],
}),
JSON.stringify({
role: 'user',
content: [
{ type: 'tool_result', content: 'orphan_no_id' },
{ type: 'tool_result', tool_use_id: 't1', content: 'matched' },
],
}),
])
expect(out).toContain('Tool1')
expect(out).toContain('matched')
expect(out).not.toContain('orphan_no_id')
})
test('extractContentBlocks: skips block of unknown type', async () => {
const out = await runWithLogLines([
JSON.stringify({
role: 'assistant',
content: [
{ type: 'text', text: 'should be ignored' },
{ type: 'tool_use', id: 't1', name: 'OnlyTool', input: 'in' },
],
}),
JSON.stringify({
role: 'user',
content: [{ type: 'tool_result', tool_use_id: 't1', content: 'r' }],
}),
])
expect(out).toContain('OnlyTool')
expect(out).not.toContain('should be ignored')
})
test('parseToolCallsFromLog: skips malformed JSON lines', async () => {
const out = await runWithLogLines([
'this-is-not-json',
JSON.stringify({
role: 'assistant',
content: [{ type: 'tool_use', id: 't1', name: 'GoodTool', input: 'x' }],
}),
'{broken json',
JSON.stringify({
role: 'user',
content: [{ type: 'tool_result', tool_use_id: 't1', content: 'y' }],
}),
])
expect(out).toContain('GoodTool')
})
test('skips entries with no content field', async () => {
const out = await runWithLogLines([
JSON.stringify({ role: 'system' }),
JSON.stringify({
role: 'assistant',
content: [{ type: 'tool_use', id: 't1', name: 'OnlyTool', input: 'x' }],
}),
JSON.stringify({
role: 'user',
content: [{ type: 'tool_result', tool_use_id: 't1', content: 'y' }],
}),
])
expect(out).toContain('OnlyTool')
})
test('tool_use without matching tool_result produces no pair', async () => {
const out = await runWithLogLines([
JSON.stringify({
role: 'assistant',
content: [
{ type: 'tool_use', id: 'orphan', name: 'OrphanTool', input: 'x' },
],
}),
])
// No pairs → "no tool call pairs found"
expect(out).toContain('No tool call')
})
test('non-numeric N argument falls back to default 5', async () => {
await makeLogWithToolCalls(claudeDir, 7)
const mod = await import('../index.js')
const cmd = mod.default
const loaded = await (
cmd as unknown as {
load: () => Promise<{
call: (
args: string,
ctx: never,
) => Promise<{ type: string; value: string }>
}>
}
).load()
const result = await loaded.call('not-a-number', {} as never)
expect(result.type).toBe('text')
if (result.type === 'text') {
// Default is 5 → "Last 5 Tool Calls (of 7 total)"
expect(result.value).toContain('Last 5 Tool Calls')
expect(result.value).toContain('of 7 total')
}
})
test('zero or negative N falls back to default', async () => {
await makeLogWithToolCalls(claudeDir, 7)
const mod = await import('../index.js')
const cmd = mod.default
const loaded = await (
cmd as unknown as {
load: () => Promise<{
call: (
args: string,
ctx: never,
) => Promise<{ type: string; value: string }>
}>
}
).load()
const result = await loaded.call('0', {} as never)
expect(result.type).toBe('text')
if (result.type === 'text') {
expect(result.value).toContain('Last 5 Tool Calls')
}
})
test('singular header when only one tool call (no plural s)', async () => {
await makeLogWithToolCalls(claudeDir, 1)
const mod = await import('../index.js')
const cmd = mod.default
const loaded = await (
cmd as unknown as {
load: () => Promise<{
call: (
args: string,
ctx: never,
) => Promise<{ type: string; value: string }>
}>
}
).load()
const result = await loaded.call('1', {} as never)
expect(result.type).toBe('text')
if (result.type === 'text') {
expect(result.value).toContain('Last 1 Tool Call ')
expect(result.value).not.toContain('Last 1 Tool Calls')
}
})
})

View File

@@ -1 +0,0 @@
export default { isEnabled: () => false, isHidden: true, name: 'stub' }

View File

@@ -0,0 +1,190 @@
import { existsSync, readFileSync } from 'node:fs'
import { join } from 'node:path'
import {
getOriginalCwd,
getSessionId,
getSessionProjectDir,
} from '../../bootstrap/state.js'
import { getClaudeConfigHomeDir } from '../../utils/envUtils.js'
import { sanitizePath } from '../../utils/path.js'
import type { Command, LocalCommandResult } from '../../types/command.js'
const DEFAULT_N = 5
const MAX_OUTPUT_LEN = 200
interface ToolUseBlock {
type: 'tool_use'
id: string
name: string
input: unknown
}
interface ToolResultBlock {
type: 'tool_result'
tool_use_id: string
content: unknown
}
interface LogEntry {
role?: string
content?: unknown
}
function getTranscriptPath(): string {
const sessionId = getSessionId()
const projectDir = getSessionProjectDir()
if (projectDir) return join(projectDir, `${sessionId}.jsonl`)
return join(
getClaudeConfigHomeDir(),
'projects',
sanitizePath(getOriginalCwd()),
`${sessionId}.jsonl`,
)
}
function truncate(s: string, maxLen: number): string {
return s.length > maxLen ? `${s.slice(0, maxLen)}` : s
}
function renderValue(v: unknown): string {
if (typeof v === 'string') return truncate(v, MAX_OUTPUT_LEN)
try {
return truncate(JSON.stringify(v, null, 2), MAX_OUTPUT_LEN)
} catch {
return String(v).slice(0, MAX_OUTPUT_LEN)
}
}
function extractContentBlocks(
content: unknown,
): Array<ToolUseBlock | ToolResultBlock> {
if (!Array.isArray(content)) return []
const result: Array<ToolUseBlock | ToolResultBlock> = []
for (const block of content as Array<Record<string, unknown>>) {
if (block.type === 'tool_use' && typeof block.id === 'string') {
result.push({
type: 'tool_use',
id: block.id,
name: typeof block.name === 'string' ? block.name : 'unknown',
input: block.input,
})
} else if (
block.type === 'tool_result' &&
typeof block.tool_use_id === 'string'
) {
result.push({
type: 'tool_result',
tool_use_id: block.tool_use_id,
content: block.content,
})
}
}
return result
}
function parseToolCallsFromLog(
logPath: string,
): Array<{ name: string; input: string; output: string }> {
const raw = readFileSync(logPath, 'utf8')
const lines = raw.trim().split('\n').filter(Boolean)
const toolUseMap = new Map<string, ToolUseBlock>()
const pairs: Array<{ name: string; input: string; output: string }> = []
for (const line of lines) {
try {
const entry = JSON.parse(line) as LogEntry
if (!entry.content) continue
const blocks = extractContentBlocks(entry.content)
for (const block of blocks) {
if (block.type === 'tool_use') {
toolUseMap.set(block.id, block)
} else if (block.type === 'tool_result') {
const use = toolUseMap.get(block.tool_use_id)
if (use) {
pairs.push({
name: use.name,
input: renderValue(use.input),
output: renderValue(block.content),
})
}
}
}
} catch {
// skip malformed lines
}
}
return pairs
}
const debugToolCall: Command = {
type: 'local',
name: 'debug-tool-call',
description:
'Show the last N tool call pairs (use/result) from the session log',
isHidden: false,
isEnabled: () => true,
supportsNonInteractive: true,
bridgeSafe: true,
load: async () => ({
call: async (args: string): Promise<LocalCommandResult> => {
const n = args.trim() ? parseInt(args.trim(), 10) : DEFAULT_N
const count = Number.isFinite(n) && n > 0 ? n : DEFAULT_N
const logPath = getTranscriptPath()
if (!existsSync(logPath)) {
return {
type: 'text',
value: [
'## Debug Tool Calls',
'',
`Log file not found: \`${logPath}\``,
'',
'No tool calls to show — the session log has not been created yet.',
].join('\n'),
}
}
const pairs = parseToolCallsFromLog(logPath)
const recent = pairs.slice(-count)
if (recent.length === 0) {
return {
type: 'text',
value: [
'## Debug Tool Calls',
'',
`No tool call pairs found in session log: \`${logPath}\``,
'',
'Tool calls appear after the model invokes a tool and receives a result.',
].join('\n'),
}
}
const lines: string[] = [
`## Last ${recent.length} Tool Call${recent.length === 1 ? '' : 's'} (of ${pairs.length} total)`,
'',
]
for (let i = 0; i < recent.length; i++) {
const pair = recent[i]
lines.push(`### [${pairs.length - recent.length + i + 1}] ${pair.name}`)
lines.push(`**Input:**`)
lines.push('```')
lines.push(pair.input)
lines.push('```')
lines.push(`**Output:**`)
lines.push('```')
lines.push(pair.output)
lines.push('```')
lines.push('')
}
return { type: 'text', value: lines.join('\n') }
},
}),
}
export default debugToolCall