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,336 @@
import { afterEach, beforeEach, describe, expect, mock, test } from 'bun:test'
import {
existsSync,
mkdirSync,
mkdtempSync,
rmSync,
unlinkSync,
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
// Dynamic envUtils mock — reads CLAUDE_CONFIG_DIR from process.env at call
// time so it stays compatible across the full suite when other test files
// also drive their own dirs via process.env.
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,
}))
async function invokeBreakCache(
args: string,
): Promise<{ type: string; value: string }> {
const { callBreakCache } = await import('../index.js')
return callBreakCache(args) as Promise<{ type: string; value: string }>
}
beforeEach(() => {
tmpDir = mkdtempSync(join(tmpdir(), 'break-cache-test-'))
claudeDir = join(tmpDir, '.claude')
mkdirSync(claudeDir, { recursive: true })
process.env.CLAUDE_CONFIG_DIR = claudeDir
})
afterEach(() => {
// Clean up any lingering marker files
try {
const { getBreakCacheMarkerPath } = require('../index.js')
const markerPath = getBreakCacheMarkerPath()
if (existsSync(markerPath)) unlinkSync(markerPath)
} catch {
// ignore
}
rmSync(tmpDir, { recursive: true, force: true })
delete process.env.CLAUDE_CONFIG_DIR
})
describe('break-cache command', () => {
test('command has correct name and type', async () => {
const mod = await import('../index.js')
const cmd = mod.default
expect(cmd.name).toBe('break-cache')
expect(cmd.type).toBe('local-jsx')
expect(cmd.argumentHint).toContain('status')
const nonInteractive = mod.breakCacheNonInteractive
expect(nonInteractive.name).toBe('break-cache')
expect(nonInteractive.type).toBe('local')
expect(
(nonInteractive as unknown as { supportsNonInteractive: boolean })
.supportsNonInteractive,
).toBe(true)
})
test('interactive and noninteractive entries are mutually gated', async () => {
const mod = await import('../index.js')
const interactiveEnabled = mod.default.isEnabled?.()
const nonInteractiveEnabled = mod.breakCacheNonInteractive.isEnabled?.()
expect(typeof interactiveEnabled).toBe('boolean')
expect(nonInteractiveEnabled).toBe(!interactiveEnabled)
})
test('writes marker file and confirms in message', async () => {
const mod = await import('../index.js')
const { getBreakCacheMarkerPath } = mod
const result = await invokeBreakCache('')
expect(result.type).toBe('text')
if (result.type === 'text') {
expect(result.value).toContain('Cache break scheduled')
expect(result.value).toContain('next API call')
}
// Marker file must exist under CLAUDE_CONFIG_DIR
const markerPath = getBreakCacheMarkerPath()
expect(markerPath).toContain('.next-request-no-cache')
expect(existsSync(markerPath)).toBe(true)
// Clean up
unlinkSync(markerPath)
})
test('--clear removes an existing marker', async () => {
const mod = await import('../index.js')
const { getBreakCacheMarkerPath } = mod
// Set the marker first
await invokeBreakCache('')
const markerPath = getBreakCacheMarkerPath()
expect(existsSync(markerPath)).toBe(true)
// Now clear it
const clearResult = await invokeBreakCache('--clear')
expect(clearResult.type).toBe('text')
if (clearResult.type === 'text') {
expect(clearResult.value).toContain('cleared')
}
expect(existsSync(markerPath)).toBe(false)
})
test('--clear when no marker returns no-marker message', async () => {
const mod = await import('../index.js')
const { getBreakCacheMarkerPath } = mod
const markerPath = getBreakCacheMarkerPath()
// Ensure it does not exist
if (existsSync(markerPath)) unlinkSync(markerPath)
const result = await invokeBreakCache('--clear')
expect(result.type).toBe('text')
if (result.type === 'text') {
expect(result.value).toContain('No cache-break marker')
}
})
test('getBreakCacheMarkerPath points inside CLAUDE_CONFIG_DIR', async () => {
const { getBreakCacheMarkerPath } = await import('../index.js')
const path = getBreakCacheMarkerPath()
expect(path).toContain('.next-request-no-cache')
// The path should be under claudeDir (CLAUDE_CONFIG_DIR)
expect(path.startsWith(claudeDir)).toBe(true)
})
test('"once" scope is same as empty args', async () => {
const mod = await import('../index.js')
const { getBreakCacheMarkerPath } = mod
const result = await invokeBreakCache('once')
expect(result.type).toBe('text')
if (result.type === 'text') {
expect(result.value).toContain('Cache break scheduled')
}
const markerPath = getBreakCacheMarkerPath()
expect(existsSync(markerPath)).toBe(true)
})
test('"always" scope writes the always flag', async () => {
const mod = await import('../index.js')
const { getBreakCacheAlwaysPath } = mod
const result = await invokeBreakCache('always')
expect(result.type).toBe('text')
if (result.type === 'text') {
expect(result.value).toContain('Always-on')
}
expect(existsSync(getBreakCacheAlwaysPath())).toBe(true)
// Clean up
unlinkSync(getBreakCacheAlwaysPath())
})
test('"off" scope clears both flags', async () => {
const mod = await import('../index.js')
const { getBreakCacheMarkerPath, getBreakCacheAlwaysPath } = mod
// Set both markers
await invokeBreakCache('')
await invokeBreakCache('always')
expect(existsSync(getBreakCacheMarkerPath())).toBe(true)
expect(existsSync(getBreakCacheAlwaysPath())).toBe(true)
// Clear both
const result = await invokeBreakCache('off')
expect(result.type).toBe('text')
if (result.type === 'text') {
expect(result.value).toContain('disabled')
}
expect(existsSync(getBreakCacheMarkerPath())).toBe(false)
expect(existsSync(getBreakCacheAlwaysPath())).toBe(false)
})
test('"status" scope shows current state', async () => {
const result = await invokeBreakCache('status')
expect(result.type).toBe('text')
if (result.type === 'text') {
expect(result.value).toContain('Break-Cache Status')
expect(result.value).toContain('Once marker')
expect(result.value).toContain('Always mode')
}
})
test('unknown scope returns usage text', async () => {
const result = await invokeBreakCache('foobar')
expect(result.type).toBe('text')
if (result.type === 'text') {
expect(result.value).toContain('Unknown scope')
expect(result.value).toContain('Usage')
}
})
test('getBreakCacheAlwaysPath and getBreakCacheStatsPath are exported', async () => {
const { getBreakCacheAlwaysPath, getBreakCacheStatsPath } = await import(
'../index.js'
)
expect(typeof getBreakCacheAlwaysPath()).toBe('string')
expect(typeof getBreakCacheStatsPath()).toBe('string')
expect(getBreakCacheAlwaysPath()).toContain('.break-cache-always')
// File was renamed to append-only JSONL (H3 fix: atomic append prevents RMW race)
expect(getBreakCacheStatsPath()).toContain('break-cache-events.jsonl')
})
// ── H3 regression: append-only stats log accumulates correctly ──
test('H3: each /break-cache once appends one event; totalBreaks reflects all calls', async () => {
const { readFileSync } = await import('node:fs')
const mod = await import('../index.js')
const { getBreakCacheStatsPath } = mod
// Call /break-cache once, twice
await invokeBreakCache('once')
await invokeBreakCache('once')
await invokeBreakCache('once')
// Stats path should be a JSONL file with 3 'once' events
const statsPath = getBreakCacheStatsPath()
const lines = readFileSync(statsPath, 'utf8')
.trim()
.split('\n')
.filter(Boolean)
const events = lines.map(l => JSON.parse(l) as { kind: string })
const onceEvents = events.filter(e => e.kind === 'once')
expect(onceEvents.length).toBe(3)
// The status command should report totalBreaks = 3
const statusResult = await invokeBreakCache('status')
if (statusResult.type === 'text') {
expect(statusResult.value).toContain('total_breaks: 3')
}
})
test('local-jsx no args renders action panel without completing', async () => {
const { call } = await import('../panel.js')
const messages: string[] = []
const node = await call(
msg => {
if (msg) messages.push(msg)
},
{} as never,
'',
)
expect(node).not.toBeNull()
expect(messages).toHaveLength(0)
})
test('local-jsx explicit args completes through onDone', async () => {
const { call } = await import('../panel.js')
const messages: string[] = []
const node = await call(
msg => {
if (msg) messages.push(msg)
},
{} as never,
'status',
)
expect(node).toBeNull()
expect(messages.join('\n')).toContain('Break-Cache Status')
})
test('readEvents skips malformed JSON lines (catch branch)', async () => {
const { getBreakCacheStatsPath } = await import('../index.js')
const statsPath = getBreakCacheStatsPath()
mkdirSync(join(statsPath, '..'), { recursive: true })
writeFileSync(
statsPath,
[
'{not valid json',
JSON.stringify({ kind: 'once', timestamp: Date.now() }),
'',
'{"truncated":',
].join('\n') + '\n',
)
// Status read uses readEvents internally → exercises the JSON.parse catch.
const result = await invokeBreakCache('status')
expect(result.type).toBe('text')
expect(result.value).toContain('Break-Cache Status')
})
test('breakCache (interactive): getBridgeInvocationError requires arg', async () => {
const mod = await import('../index.js')
const cmd = mod.default
const fn = (
cmd as unknown as {
getBridgeInvocationError?: (args: string) => string | undefined
}
).getBridgeInvocationError
expect(typeof fn).toBe('function')
if (fn) {
expect(fn('')).toContain('Remote Control')
expect(fn(' ')).toContain('Remote Control')
expect(fn('once')).toBeUndefined()
expect(fn('status')).toBeUndefined()
}
})
test('breakCacheNonInteractive: load() returns call function', async () => {
const { breakCacheNonInteractive } = await import('../index.js')
expect(breakCacheNonInteractive.type).toBe('local')
const loaded = await (
breakCacheNonInteractive as unknown as {
load: () => Promise<{ call: unknown }>
}
).load()
expect(typeof loaded.call).toBe('function')
})
})

View File

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

View File

@@ -0,0 +1,275 @@
import {
appendFileSync,
existsSync,
mkdirSync,
readFileSync,
unlinkSync,
writeFileSync,
} from 'node:fs'
import { join } from 'node:path'
import { getIsNonInteractiveSession } from '../../bootstrap/state.js'
import { getClaudeConfigHomeDir } from '../../utils/envUtils.js'
import type { Command, LocalCommandResult } from '../../types/command.js'
/**
* Path to the next-request-no-cache marker file.
* When this file exists, the main API call path should append a random
* comment to the system prompt to bust the prefix-cache hash, then delete it.
*
* Convention: public so other modules (e.g. claude.ts) can check it.
*/
export function getBreakCacheMarkerPath(): string {
return join(getClaudeConfigHomeDir(), '.next-request-no-cache')
}
/**
* Path to the always-on break-cache flag file.
* When this file exists, EVERY API request gets a cache-busting nonce
* (instead of just the next one).
*/
export function getBreakCacheAlwaysPath(): string {
return join(getClaudeConfigHomeDir(), '.break-cache-always')
}
/**
* Path to the append-only JSONL log that records each cache-break event.
*
* Replaces the old read-modify-write stats JSON to avoid lost increments when
* two concurrent `/break-cache once` invocations race. Each break appends one
* line; `readStats()` aggregates at read time.
*
* Uses getClaudeConfigHomeDir() so that CLAUDE_CONFIG_DIR env var overrides
* the path in test environments.
*/
export function getBreakCacheStatsPath(): string {
return join(getClaudeConfigHomeDir(), 'break-cache-events.jsonl')
}
interface BreakCacheStats {
totalBreaks: number
lastBreakAt: string | null
alwaysModeEnabled: boolean
}
interface BreakCacheEvent {
at: string
kind: 'once' | 'always_on' | 'always_off'
}
/**
* Reads stats by aggregating the append-only event log.
* Because we only append, concurrent writers cannot lose increments.
*/
function readStats(): BreakCacheStats {
try {
const raw = readFileSync(getBreakCacheStatsPath(), 'utf8')
const events = raw
.trim()
.split('\n')
.filter(Boolean)
.map(line => {
try {
return JSON.parse(line) as BreakCacheEvent
} catch {
return null
}
})
.filter((e): e is BreakCacheEvent => e !== null)
const onceBreaks = events.filter(e => e.kind === 'once')
const lastEvent = events[events.length - 1]
const alwaysEvents = events.filter(
e => e.kind === 'always_on' || e.kind === 'always_off',
)
const lastAlways = alwaysEvents[alwaysEvents.length - 1]
return {
totalBreaks: onceBreaks.length,
lastBreakAt: lastEvent?.at ?? null,
alwaysModeEnabled: lastAlways?.kind === 'always_on',
}
} catch {
return { totalBreaks: 0, lastBreakAt: null, alwaysModeEnabled: false }
}
}
/**
* Appends a single event line to the stats log.
* append is atomic at the OS level for small writes, so concurrent callers
* cannot overwrite each other's increments.
*/
function appendBreakEvent(kind: BreakCacheEvent['kind']): void {
const statsPath = getBreakCacheStatsPath()
mkdirSync(getClaudeConfigHomeDir(), { recursive: true })
const event: BreakCacheEvent = { at: new Date().toISOString(), kind }
appendFileSync(statsPath, JSON.stringify(event) + '\n', 'utf8')
}
function incrementBreakCount(): void {
appendBreakEvent('once')
}
const USAGE_TEXT = [
'Usage: /break-cache [scope]',
'',
' (no args) Schedule a one-time cache break for the next API call',
' once Same as no args',
' always Enable persistent cache-break mode (every request)',
' off Disable always mode and clear any pending marker',
' --clear Clear the pending once marker (cancel before next call)',
' status Show current break-cache status and stats',
'',
'How it works:',
' The Anthropic prompt cache keys on the system-prompt prefix hash.',
' A unique nonce invalidates the hash, forcing a fresh compute.',
' This is useful when you want to ensure a clean context window.',
].join('\n')
export async function callBreakCache(
args: string,
): Promise<LocalCommandResult> {
const scope = args.trim().toLowerCase()
const markerPath = getBreakCacheMarkerPath()
const alwaysPath = getBreakCacheAlwaysPath()
// ── status ──
if (scope === 'status') {
const stats = readStats()
const onceActive = existsSync(markerPath)
const alwaysActive = existsSync(alwaysPath)
return {
type: 'text',
value: [
'## Break-Cache Status',
'',
` Once marker: ${onceActive ? 'ACTIVE (next call will bust cache)' : 'not set'}`,
` Always mode: ${alwaysActive ? 'ON (every call busts cache)' : 'off'}`,
'',
'## Stats',
` total_breaks: ${stats.totalBreaks}`,
` last_break_at: ${stats.lastBreakAt ?? 'never'}`,
].join('\n'),
}
}
// ── off ──
if (scope === 'off') {
let cleared = false
if (existsSync(markerPath)) {
unlinkSync(markerPath)
cleared = true
}
if (existsSync(alwaysPath)) {
unlinkSync(alwaysPath)
cleared = true
}
appendBreakEvent('always_off')
return {
type: 'text',
value: cleared
? 'Break-cache disabled. Removed once marker and/or always flag.'
: 'Break-cache was not active.',
}
}
// ── --clear ──
if (scope === '--clear') {
if (existsSync(markerPath)) {
unlinkSync(markerPath)
return {
type: 'text',
value: `Cache-break marker cleared.\n \`${markerPath}\``,
}
}
return {
type: 'text',
value: 'No cache-break marker was set.',
}
}
// ── always ──
if (scope === 'always') {
writeFileSync(alwaysPath, new Date().toISOString(), 'utf8')
appendBreakEvent('always_on')
return {
type: 'text',
value: [
'## Always-on cache break enabled',
'',
`Flag written: \`${alwaysPath}\``,
'',
'Every API call will now append a random nonce to the system prompt,',
'permanently preventing prompt-cache hits for this session.',
'',
'To disable: `/break-cache off`',
].join('\n'),
}
}
// ── once (legacy default, or explicit "once") ──
if (scope === '' || scope === 'once') {
const timestamp = new Date().toISOString()
writeFileSync(markerPath, timestamp, 'utf8')
incrementBreakCount()
const stats = readStats()
return {
type: 'text',
value: [
'## Cache break scheduled',
'',
`Marker written: \`${markerPath}\``,
`Timestamp: ${timestamp}`,
'',
'The next API call will append a random nonce to the system prompt,',
'causing a cache miss. The marker is removed automatically after use.',
'',
'To cancel before the next call: `/break-cache --clear`',
'For every call: `/break-cache always`',
'',
`Total breaks this session: ${stats.totalBreaks}`,
'',
'_How it works: Anthropic prompt cache keys on the system-prompt prefix hash._',
'_A unique nonce invalidates the hash, forcing a fresh compute._',
].join('\n'),
}
}
// ── unknown scope ──
return {
type: 'text',
value: [`Unknown scope: "${scope}"`, '', USAGE_TEXT].join('\n'),
}
}
const breakCache: Command = {
type: 'local-jsx',
name: 'break-cache',
description:
'Manage prompt-cache breaking. Open actions or run: once, status, always, off',
isHidden: false,
isEnabled: () => !getIsNonInteractiveSession(),
argumentHint: '[once|status|always|off|--clear]',
bridgeSafe: true,
getBridgeInvocationError: args =>
args.trim()
? undefined
: 'Use /break-cache once/status/always/off over Remote Control.',
load: () => import('./panel.js'),
}
export const breakCacheNonInteractive: Command = {
type: 'local',
name: 'break-cache',
description:
'Force the next (or all) API call(s) to miss prompt cache. Scopes: once, status, always, off',
isHidden: false,
isEnabled: () => getIsNonInteractiveSession(),
supportsNonInteractive: true,
bridgeSafe: true,
load: async () => ({
call: callBreakCache,
}),
}
export default breakCache

View File

@@ -0,0 +1,105 @@
import React, { useMemo, useState } from 'react';
import { Box, Dialog, Text, useInput } from '@anthropic/ink';
import type { LocalJSXCommandOnDone } from '../../types/command.js';
import { callBreakCache } from './index.js';
type BreakCacheAction = {
label: string;
description: string;
run: () => void;
};
const ACTION_LABEL_COLUMN_WIDTH = 28;
async function runBreakCacheAction(scope: string, onDone: LocalJSXCommandOnDone): Promise<void> {
const result = await callBreakCache(scope);
if (result.type === 'text') {
onDone(result.value, { display: 'system' });
}
}
function BreakCachePanel({ onDone }: { onDone: LocalJSXCommandOnDone }): React.ReactNode {
const [selectedIndex, setSelectedIndex] = useState(0);
const actions = useMemo<BreakCacheAction[]>(
() => [
{
label: 'Status',
description: 'Show pending marker, always mode, and break count',
run: () => void runBreakCacheAction('status', onDone),
},
{
label: 'Once',
description: 'Break prompt cache on the next API call only',
run: () => void runBreakCacheAction('once', onDone),
},
{
label: 'Always',
description: 'Break prompt cache on every API call',
run: () => void runBreakCacheAction('always', onDone),
},
{
label: 'Off',
description: 'Disable always mode and clear pending once marker',
run: () => void runBreakCacheAction('off', onDone),
},
{
label: 'Clear Once',
description: 'Cancel the pending one-time cache break',
run: () => void runBreakCacheAction('--clear', onDone),
},
],
[onDone],
);
const selectCurrent = () => {
const action = actions[selectedIndex];
if (!action) return;
action.run();
};
useInput((_input, key) => {
if (key.upArrow) {
setSelectedIndex(index => Math.max(0, index - 1));
return;
}
if (key.downArrow) {
setSelectedIndex(index => Math.min(actions.length - 1, index + 1));
return;
}
if (key.return) {
selectCurrent();
}
});
return (
<Dialog
title="Break Cache"
subtitle={`${actions.length} actions`}
onCancel={() => onDone('Break-cache panel dismissed', { display: 'system' })}
color="background"
hideInputGuide
>
<Box flexDirection="column">
{actions.map((action, index) => (
<Box key={action.label} flexDirection="row">
<Text>{`${index === selectedIndex ? '' : ' '} ${action.label}`.padEnd(ACTION_LABEL_COLUMN_WIDTH)}</Text>
<Text dimColor>{action.description}</Text>
</Box>
))}
<Box marginTop={1}>
<Text dimColor>/ select · Enter run · Esc close</Text>
</Box>
</Box>
</Dialog>
);
}
export async function call(onDone: LocalJSXCommandOnDone, _context: unknown, args?: string): Promise<React.ReactNode> {
const trimmed = args?.trim() ?? '';
if (trimmed) {
await runBreakCacheAction(trimmed, onDone);
return null;
}
return <BreakCachePanel onDone={onDone} />;
}