mirror of
https://github.com/claude-code-best/claude-code.git
synced 2026-06-15 12:55:51 +00:00
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:
336
src/commands/break-cache/__tests__/break-cache.test.ts
Normal file
336
src/commands/break-cache/__tests__/break-cache.test.ts
Normal 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')
|
||||
})
|
||||
})
|
||||
@@ -1 +0,0 @@
|
||||
export default { isEnabled: () => false, isHidden: true, name: 'stub' }
|
||||
275
src/commands/break-cache/index.ts
Normal file
275
src/commands/break-cache/index.ts
Normal 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
|
||||
105
src/commands/break-cache/panel.tsx
Normal file
105
src/commands/break-cache/panel.tsx
Normal 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} />;
|
||||
}
|
||||
@@ -1,23 +1,8 @@
|
||||
/**
|
||||
* Cost command - minimal metadata only.
|
||||
* Implementation is lazy-loaded from cost.ts to reduce startup time.
|
||||
* /cost — alias for /usage (v2.1.118 upstream alignment).
|
||||
*
|
||||
* /usage is the primary command; /cost and /stats are registered as aliases.
|
||||
* This file re-exports the unified usage command so that any code that imports
|
||||
* from cost/index directly still gets the correct Command object.
|
||||
*/
|
||||
import type { Command } from '../../commands.js'
|
||||
import { isClaudeAISubscriber } from '../../utils/auth.js'
|
||||
|
||||
const cost = {
|
||||
type: 'local',
|
||||
name: 'cost',
|
||||
description: 'Show the total cost and duration of the current session',
|
||||
get isHidden() {
|
||||
// Keep visible for Ants even if they're subscribers (they see cost breakdowns)
|
||||
if (process.env.USER_TYPE === 'ant') {
|
||||
return false
|
||||
}
|
||||
return isClaudeAISubscriber()
|
||||
},
|
||||
supportsNonInteractive: true,
|
||||
load: () => import('./cost.js'),
|
||||
} satisfies Command
|
||||
|
||||
export default cost
|
||||
export { default } from '../usage/index.js'
|
||||
|
||||
575
src/commands/debug-tool-call/__tests__/debug-tool-call.test.ts
Normal file
575
src/commands/debug-tool-call/__tests__/debug-tool-call.test.ts
Normal 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')
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -1 +0,0 @@
|
||||
export default { isEnabled: () => false, isHidden: true, name: 'stub' }
|
||||
190
src/commands/debug-tool-call/index.ts
Normal file
190
src/commands/debug-tool-call/index.ts
Normal 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
|
||||
182
src/commands/env/__tests__/env.test.ts
vendored
Normal file
182
src/commands/env/__tests__/env.test.ts
vendored
Normal file
@@ -0,0 +1,182 @@
|
||||
/**
|
||||
* Tests for src/commands/env/index.ts
|
||||
* Covers: isSecretKey, maskValue, ENV_PREFIX_ALLOWLIST branches, formatRuntime, full call()
|
||||
*
|
||||
* Note: We do NOT mock src/bootstrap/state.js here to avoid the incomplete-mock
|
||||
* cross-test pollution described in tests/mocks/README. The real state module
|
||||
* is safe to import (getSessionId() returns a stable UUID per process).
|
||||
*/
|
||||
import { afterEach, beforeAll, describe, expect, test } from 'bun:test'
|
||||
|
||||
let envCmd: {
|
||||
load?: () => Promise<{ call: () => Promise<{ type: string; value: string }> }>
|
||||
isEnabled?: () => boolean
|
||||
supportsNonInteractive?: boolean
|
||||
name?: string
|
||||
}
|
||||
|
||||
beforeAll(async () => {
|
||||
const mod = await import('../index.js')
|
||||
envCmd = mod.default as typeof envCmd
|
||||
})
|
||||
|
||||
describe('env command metadata', () => {
|
||||
test('isEnabled returns true', () => {
|
||||
expect(envCmd.isEnabled?.()).toBe(true)
|
||||
})
|
||||
|
||||
test('supportsNonInteractive is true', () => {
|
||||
expect(envCmd.supportsNonInteractive).toBe(true)
|
||||
})
|
||||
|
||||
test('name is "env"', () => {
|
||||
expect(envCmd.name).toBe('env')
|
||||
})
|
||||
|
||||
test('type is local', async () => {
|
||||
const mod = await import('../index.js')
|
||||
const cmd = mod.default as { type?: string }
|
||||
expect(cmd.type).toBe('local')
|
||||
})
|
||||
})
|
||||
|
||||
describe('env command output', () => {
|
||||
const savedEnvVars: Record<string, string | undefined> = {}
|
||||
|
||||
afterEach(() => {
|
||||
// Restore env vars set during tests
|
||||
for (const [k, v] of Object.entries(savedEnvVars)) {
|
||||
if (v === undefined) {
|
||||
delete process.env[k]
|
||||
} else {
|
||||
process.env[k] = v
|
||||
}
|
||||
}
|
||||
Object.keys(savedEnvVars).forEach(k => delete savedEnvVars[k])
|
||||
})
|
||||
|
||||
function setEnv(key: string, value: string): void {
|
||||
savedEnvVars[key] = process.env[key]
|
||||
process.env[key] = value
|
||||
}
|
||||
|
||||
function deleteEnv(key: string): void {
|
||||
savedEnvVars[key] = process.env[key]
|
||||
delete process.env[key]
|
||||
}
|
||||
|
||||
test('call() returns type=text', async () => {
|
||||
const loaded = await envCmd.load!()
|
||||
const result = await loaded.call()
|
||||
expect(result.type).toBe('text')
|
||||
})
|
||||
|
||||
test('call() contains ## Runtime section', async () => {
|
||||
const loaded = await envCmd.load!()
|
||||
const result = await loaded.call()
|
||||
expect(result.value).toContain('## Runtime')
|
||||
})
|
||||
|
||||
test('call() contains ## Environment Variables section', async () => {
|
||||
const loaded = await envCmd.load!()
|
||||
const result = await loaded.call()
|
||||
expect(result.value).toContain('## Environment Variables')
|
||||
})
|
||||
|
||||
test('call() contains platform info', async () => {
|
||||
const loaded = await envCmd.load!()
|
||||
const result = await loaded.call()
|
||||
expect(result.value).toContain('platform:')
|
||||
})
|
||||
|
||||
test('call() contains session field', async () => {
|
||||
const loaded = await envCmd.load!()
|
||||
const result = await loaded.call()
|
||||
expect(result.value).toContain('session:')
|
||||
})
|
||||
|
||||
test('CLAUDE_ prefixed var appears in output', async () => {
|
||||
setEnv('CLAUDE_TEST_MYVAR', 'hello_env')
|
||||
const loaded = await envCmd.load!()
|
||||
const result = await loaded.call()
|
||||
expect(result.value).toContain('CLAUDE_TEST_MYVAR=hello_env')
|
||||
})
|
||||
|
||||
test('FEATURE_ var appears in output', async () => {
|
||||
setEnv('FEATURE_MYTEST', '1')
|
||||
const loaded = await envCmd.load!()
|
||||
const result = await loaded.call()
|
||||
expect(result.value).toContain('FEATURE_MYTEST=1')
|
||||
})
|
||||
|
||||
test('secret key (token) value is masked — short value shows ***', async () => {
|
||||
setEnv('CLAUDE_TEST_TOKEN', 'short')
|
||||
const loaded = await envCmd.load!()
|
||||
const result = await loaded.call()
|
||||
expect(result.value).toContain('CLAUDE_TEST_TOKEN=***')
|
||||
})
|
||||
|
||||
test('secret key (token) value is masked — long value shows partial with length', async () => {
|
||||
setEnv('CLAUDE_TEST_TOKEN', 'verylongtokenvalue1234')
|
||||
const loaded = await envCmd.load!()
|
||||
const result = await loaded.call()
|
||||
expect(result.value).not.toContain('verylongtokenvalue1234')
|
||||
expect(result.value).toContain('CLAUDE_TEST_TOKEN=very')
|
||||
expect(result.value).toContain('chars)')
|
||||
})
|
||||
|
||||
test('non-allowlisted var does NOT appear in output', async () => {
|
||||
setEnv('RANDOM_UNRELATED_TEST_VAR', 'should-not-appear')
|
||||
const loaded = await envCmd.load!()
|
||||
const result = await loaded.call()
|
||||
expect(result.value).not.toContain('RANDOM_UNRELATED_TEST_VAR')
|
||||
})
|
||||
|
||||
test('password key is recognized as secret', async () => {
|
||||
setEnv('ANTHROPIC_TEST_PASSWORD', 'mysecret12345')
|
||||
const loaded = await envCmd.load!()
|
||||
const result = await loaded.call()
|
||||
expect(result.value).not.toContain('mysecret12345')
|
||||
expect(result.value).toContain('ANTHROPIC_TEST_PASSWORD=')
|
||||
})
|
||||
|
||||
test('no recognized env vars shows placeholder when all removed', async () => {
|
||||
const allowlistPrefixes = [
|
||||
'CLAUDE_',
|
||||
'FEATURE_',
|
||||
'ANTHROPIC_',
|
||||
'BUN_',
|
||||
'NODE_',
|
||||
'GEMINI_',
|
||||
'OPENAI_',
|
||||
'GROK_',
|
||||
'CCR_',
|
||||
'KAIROS_',
|
||||
'BUGHUNTER_',
|
||||
]
|
||||
for (const key of Object.keys(process.env)) {
|
||||
if (allowlistPrefixes.some(p => key.startsWith(p))) {
|
||||
deleteEnv(key)
|
||||
}
|
||||
}
|
||||
const loaded = await envCmd.load!()
|
||||
const result = await loaded.call()
|
||||
expect(result.value).toContain('(no recognized env vars set)')
|
||||
})
|
||||
|
||||
// ── M1 regression: KAIROS_ prefix must include underscore ──
|
||||
test('M1: KAIROS_ var (with underscore) appears in output', async () => {
|
||||
setEnv('KAIROS_MY_VAR', 'kairos_value')
|
||||
const loaded = await envCmd.load!()
|
||||
const result = await loaded.call()
|
||||
expect(result.value).toContain('KAIROS_MY_VAR=kairos_value')
|
||||
})
|
||||
|
||||
test('M1: KAIROSE_ (wrong prefix, no match) does NOT appear in output', async () => {
|
||||
// KAIROSE_ should NOT be shown — only exact KAIROS_ prefix is allowed
|
||||
setEnv('KAIROSE_INTERNAL', 'should_not_appear')
|
||||
const loaded = await envCmd.load!()
|
||||
const result = await loaded.call()
|
||||
expect(result.value).not.toContain('KAIROSE_INTERNAL')
|
||||
})
|
||||
})
|
||||
1
src/commands/env/index.js
vendored
1
src/commands/env/index.js
vendored
@@ -1 +0,0 @@
|
||||
export default { isEnabled: () => false, isHidden: true, name: 'stub' }
|
||||
102
src/commands/env/index.ts
vendored
Normal file
102
src/commands/env/index.ts
vendored
Normal file
@@ -0,0 +1,102 @@
|
||||
import type { Command, LocalCommandResult } from '../../types/command.js'
|
||||
import { getSessionId } from '../../bootstrap/state.js'
|
||||
|
||||
/**
|
||||
* /env — show the user a snapshot of the current environment, claude config,
|
||||
* feature flags, and version info. All secrets are masked.
|
||||
*
|
||||
* Pure-local command: no Anthropic backend dependency. Restored from stub
|
||||
* 2026-04-29 (was Anthropic-internal in upstream; safe to expose to fork
|
||||
* users since output is local-only).
|
||||
*/
|
||||
|
||||
const SECRET_KEY_PATTERNS = [
|
||||
/token/i,
|
||||
/secret/i,
|
||||
/password/i,
|
||||
/api[_-]?key/i,
|
||||
/auth/i,
|
||||
/private/i,
|
||||
/credential/i,
|
||||
/jwt/i,
|
||||
/session[_-]?id$/i,
|
||||
]
|
||||
|
||||
function isSecretKey(key: string): boolean {
|
||||
return SECRET_KEY_PATTERNS.some(rx => rx.test(key))
|
||||
}
|
||||
|
||||
function maskValue(value: string): string {
|
||||
if (value.length <= 8) return '***'
|
||||
return `${value.slice(0, 4)}…${value.slice(-2)} (${value.length} chars)`
|
||||
}
|
||||
|
||||
const ENV_PREFIX_ALLOWLIST = [
|
||||
'CLAUDE_',
|
||||
'FEATURE_',
|
||||
'ANTHROPIC_',
|
||||
'BUN_',
|
||||
'NODE_',
|
||||
'GEMINI_',
|
||||
'OPENAI_',
|
||||
'GROK_',
|
||||
'CCR_',
|
||||
'KAIROS_',
|
||||
'BUGHUNTER_',
|
||||
]
|
||||
|
||||
function shouldShowEnv(key: string): boolean {
|
||||
return ENV_PREFIX_ALLOWLIST.some(prefix => key.startsWith(prefix))
|
||||
}
|
||||
|
||||
function formatEnvVars(): string {
|
||||
const entries = Object.entries(process.env)
|
||||
.filter(([k]) => shouldShowEnv(k))
|
||||
.map(([k, v]): [string, string] => {
|
||||
const display = isSecretKey(k) && v ? maskValue(v) : (v ?? '')
|
||||
return [k, display]
|
||||
})
|
||||
.sort(([a], [b]) => a.localeCompare(b))
|
||||
|
||||
if (entries.length === 0) {
|
||||
return ' (no recognized env vars set)'
|
||||
}
|
||||
return entries.map(([k, v]) => ` ${k}=${v}`).join('\n')
|
||||
}
|
||||
|
||||
function formatRuntime(): string {
|
||||
const lines = [
|
||||
` platform: ${process.platform} ${process.arch}`,
|
||||
` cwd: ${process.cwd()}`,
|
||||
` pid: ${process.pid}`,
|
||||
` bun: ${typeof Bun !== 'undefined' ? Bun.version : 'n/a'}`,
|
||||
` node: ${process.version}`,
|
||||
` session: ${getSessionId()}`,
|
||||
]
|
||||
return lines.join('\n')
|
||||
}
|
||||
|
||||
const env: Command = {
|
||||
type: 'local',
|
||||
name: 'env',
|
||||
description: 'Show current environment, runtime, and feature flags',
|
||||
isHidden: false,
|
||||
isEnabled: () => true,
|
||||
supportsNonInteractive: true,
|
||||
load: async () => ({
|
||||
call: async (): Promise<LocalCommandResult> => {
|
||||
const text = [
|
||||
'## Runtime',
|
||||
formatRuntime(),
|
||||
'',
|
||||
'## Environment Variables (allowlisted prefixes)',
|
||||
formatEnvVars(),
|
||||
'',
|
||||
'_Secrets matching token/password/auth/api_key are masked. Set additional `CLAUDE_*` / `FEATURE_*` env vars to see them here._',
|
||||
].join('\n')
|
||||
return { type: 'text', value: text }
|
||||
},
|
||||
}),
|
||||
}
|
||||
|
||||
export default env
|
||||
288
src/commands/onboarding/__tests__/onboarding.test.tsx
Normal file
288
src/commands/onboarding/__tests__/onboarding.test.tsx
Normal file
@@ -0,0 +1,288 @@
|
||||
import { afterAll, afterEach, beforeEach, describe, expect, mock, test } from 'bun:test';
|
||||
import * as React from 'react';
|
||||
import { logMock } from '../../../../tests/mocks/log';
|
||||
import { debugMock } from '../../../../tests/mocks/debug';
|
||||
|
||||
// Pre-import real ink so we can fall through after this suite. Bun's
|
||||
// mock.module is process-global / last-write-wins; without delegation the
|
||||
// stub Box/Pane/Text/useTheme leak into other test files (e.g.
|
||||
// AgentsPlatformView.test.tsx) that need real ink components.
|
||||
const _realOnboardingInkMod = (await import('@anthropic/ink')) as Record<string, unknown>;
|
||||
let _useStubInkForOnboarding = true;
|
||||
afterAll(() => {
|
||||
_useStubInkForOnboarding = false;
|
||||
});
|
||||
|
||||
mock.module('bun:bundle', () => ({
|
||||
feature: (_name: string) => false,
|
||||
}));
|
||||
|
||||
mock.module('src/utils/log.ts', logMock);
|
||||
mock.module('src/utils/debug.ts', debugMock);
|
||||
|
||||
const loggedEvents: Array<{ name: string; payload: unknown }> = [];
|
||||
mock.module('src/services/analytics/index.js', () => ({
|
||||
logEvent: (name: string, payload: unknown) => {
|
||||
loggedEvents.push({ name, payload });
|
||||
},
|
||||
}));
|
||||
|
||||
// In-memory config used by the global/project config helpers so the
|
||||
// command's persistence path is exercised without touching disk.
|
||||
const fakeGlobalConfig: {
|
||||
theme?: string;
|
||||
hasCompletedOnboarding?: boolean;
|
||||
lastOnboardingVersion?: string;
|
||||
} = {};
|
||||
const fakeProjectConfig: { hasTrustDialogAccepted?: boolean } = {};
|
||||
|
||||
mock.module('src/utils/config.js', () => ({
|
||||
getGlobalConfig: () => ({ ...fakeGlobalConfig }),
|
||||
saveGlobalConfig: (updater: (cur: typeof fakeGlobalConfig) => typeof fakeGlobalConfig) => {
|
||||
Object.assign(fakeGlobalConfig, updater({ ...fakeGlobalConfig }));
|
||||
},
|
||||
saveCurrentProjectConfig: (updater: (cur: typeof fakeProjectConfig) => typeof fakeProjectConfig) => {
|
||||
Object.assign(fakeProjectConfig, updater({ ...fakeProjectConfig }));
|
||||
},
|
||||
}));
|
||||
|
||||
// Stub heavy theme + ink imports — the launcher only references them for
|
||||
// the `theme` subcommand JSX render path. Spread real ink so when the flag
|
||||
// flips off in afterAll, later test files see real components.
|
||||
mock.module('@anthropic/ink', () => {
|
||||
if (_useStubInkForOnboarding) {
|
||||
return {
|
||||
..._realOnboardingInkMod,
|
||||
Box: ({ children }: { children?: React.ReactNode }) => React.createElement('box', null, children),
|
||||
Pane: ({ children }: { children?: React.ReactNode }) => React.createElement('pane', null, children),
|
||||
Text: ({ children }: { children?: React.ReactNode }) => React.createElement('text', null, children),
|
||||
useTheme: () => ['dark', (_t: string) => undefined],
|
||||
};
|
||||
}
|
||||
return _realOnboardingInkMod;
|
||||
});
|
||||
|
||||
mock.module('src/components/ThemePicker.js', () => ({
|
||||
ThemePicker: () => React.createElement('theme-picker'),
|
||||
}));
|
||||
|
||||
import { callOnboarding, parseSubcommand, type OnboardingSubcommand } from '../launchOnboarding.js';
|
||||
import onboardingCommand from '../index.js';
|
||||
import type { LocalJSXCommandContext } from '../../../types/command.js';
|
||||
|
||||
type DoneCall = { msg?: string; opts?: { display?: string } };
|
||||
|
||||
function makeContext(): LocalJSXCommandContext {
|
||||
return {} as unknown as LocalJSXCommandContext;
|
||||
}
|
||||
|
||||
function makeOnDone(): {
|
||||
fn: (msg?: string, opts?: { display?: string }) => void;
|
||||
calls: DoneCall[];
|
||||
} {
|
||||
const calls: DoneCall[] = [];
|
||||
return {
|
||||
fn: (msg, opts) => {
|
||||
calls.push({ msg, opts });
|
||||
},
|
||||
calls,
|
||||
};
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
loggedEvents.length = 0;
|
||||
for (const k of Object.keys(fakeGlobalConfig)) delete (fakeGlobalConfig as Record<string, unknown>)[k];
|
||||
for (const k of Object.keys(fakeProjectConfig)) delete (fakeProjectConfig as Record<string, unknown>)[k];
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
loggedEvents.length = 0;
|
||||
});
|
||||
|
||||
describe('onboarding command metadata', () => {
|
||||
test('has correct name and description', () => {
|
||||
expect(onboardingCommand.name).toBe('onboarding');
|
||||
expect(onboardingCommand.description).toContain('first-run setup');
|
||||
});
|
||||
|
||||
test('is local-jsx, enabled, visible, not bridge-safe', () => {
|
||||
expect(onboardingCommand.type).toBe('local-jsx');
|
||||
expect(onboardingCommand.isEnabled?.()).toBe(true);
|
||||
expect(onboardingCommand.isHidden).toBe(false);
|
||||
expect(onboardingCommand.bridgeSafe).toBe(false);
|
||||
});
|
||||
|
||||
test('bridge invocation always rejected with an explanation', () => {
|
||||
const reason = onboardingCommand.getBridgeInvocationError?.('full');
|
||||
expect(reason).toBeTruthy();
|
||||
expect(reason).toContain('bridge');
|
||||
});
|
||||
|
||||
test('has descriptive argumentHint listing subcommands', () => {
|
||||
expect(onboardingCommand.argumentHint).toBe('[full|theme|trust|model|mcp|status]');
|
||||
});
|
||||
|
||||
test('load() returns a module with a call() function', async () => {
|
||||
if (onboardingCommand.type !== 'local-jsx') {
|
||||
throw new Error('expected local-jsx command');
|
||||
}
|
||||
const mod = await onboardingCommand.load();
|
||||
expect(typeof mod.call).toBe('function');
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseSubcommand', () => {
|
||||
test.each<[string, OnboardingSubcommand]>([
|
||||
['', 'full'],
|
||||
[' ', 'full'],
|
||||
['full', 'full'],
|
||||
['FULL', 'full'],
|
||||
['reset', 'full'],
|
||||
['theme', 'theme'],
|
||||
['trust', 'trust'],
|
||||
['model', 'model'],
|
||||
['mcp', 'mcp'],
|
||||
['status', 'status'],
|
||||
])('parses %p → %p', (input, expected) => {
|
||||
expect(parseSubcommand(input)).toEqual({ sub: expected });
|
||||
});
|
||||
|
||||
test('unknown arg returns full + unknownArg', () => {
|
||||
expect(parseSubcommand('garbage')).toEqual({
|
||||
sub: 'full',
|
||||
unknownArg: 'garbage',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('callOnboarding behavior', () => {
|
||||
test('full (no args) clears hasCompletedOnboarding and emits system message', async () => {
|
||||
fakeGlobalConfig.hasCompletedOnboarding = true;
|
||||
const { fn, calls } = makeOnDone();
|
||||
const result = await callOnboarding(fn, makeContext(), '');
|
||||
expect(result).toBeNull();
|
||||
expect(fakeGlobalConfig.hasCompletedOnboarding).toBe(false);
|
||||
expect(calls).toHaveLength(1);
|
||||
expect(calls[0]?.opts?.display).toBe('system');
|
||||
expect(calls[0]?.msg).toContain('Onboarding flag cleared');
|
||||
expect(loggedEvents.some(e => e.name === 'tengu_onboarding_step')).toBe(true);
|
||||
});
|
||||
|
||||
test('reset alias also runs the full path', async () => {
|
||||
fakeGlobalConfig.hasCompletedOnboarding = true;
|
||||
const { fn } = makeOnDone();
|
||||
await callOnboarding(fn, makeContext(), 'reset');
|
||||
expect(fakeGlobalConfig.hasCompletedOnboarding).toBe(false);
|
||||
});
|
||||
|
||||
test('theme subcommand returns a React element (theme picker)', async () => {
|
||||
const { fn } = makeOnDone();
|
||||
const result = await callOnboarding(fn, makeContext(), 'theme');
|
||||
expect(React.isValidElement(result)).toBe(true);
|
||||
});
|
||||
|
||||
test('trust subcommand clears project trust and notifies', async () => {
|
||||
fakeProjectConfig.hasTrustDialogAccepted = true;
|
||||
const { fn, calls } = makeOnDone();
|
||||
const result = await callOnboarding(fn, makeContext(), 'trust');
|
||||
expect(result).toBeNull();
|
||||
expect(fakeProjectConfig.hasTrustDialogAccepted).toBe(false);
|
||||
expect(calls[0]?.msg).toContain('trust cleared');
|
||||
});
|
||||
|
||||
test('model subcommand prints /model deferral hint', async () => {
|
||||
const { fn, calls } = makeOnDone();
|
||||
const result = await callOnboarding(fn, makeContext(), 'model');
|
||||
expect(result).toBeNull();
|
||||
expect(calls[0]?.msg).toContain('/model');
|
||||
});
|
||||
|
||||
test('mcp subcommand prints MCP setup hints', async () => {
|
||||
const { fn, calls } = makeOnDone();
|
||||
const result = await callOnboarding(fn, makeContext(), 'mcp');
|
||||
expect(result).toBeNull();
|
||||
expect(calls[0]?.msg).toContain('mcp add');
|
||||
expect(calls[0]?.msg).toContain('.mcp.json');
|
||||
});
|
||||
|
||||
test('status subcommand renders state view (React element)', async () => {
|
||||
fakeGlobalConfig.theme = 'dark';
|
||||
fakeGlobalConfig.hasCompletedOnboarding = true;
|
||||
fakeGlobalConfig.lastOnboardingVersion = '2.1.888';
|
||||
const { fn } = makeOnDone();
|
||||
const result = await callOnboarding(fn, makeContext(), 'status');
|
||||
expect(React.isValidElement(result)).toBe(true);
|
||||
});
|
||||
|
||||
test('status subcommand falls back to (unset) for missing values', async () => {
|
||||
const { fn } = makeOnDone();
|
||||
const result = await callOnboarding(fn, makeContext(), 'status');
|
||||
expect(React.isValidElement(result)).toBe(true);
|
||||
});
|
||||
|
||||
test('status JSX exposes theme/version values via props', async () => {
|
||||
fakeGlobalConfig.theme = 'light';
|
||||
fakeGlobalConfig.hasCompletedOnboarding = true;
|
||||
fakeGlobalConfig.lastOnboardingVersion = '1.2.3';
|
||||
const { fn } = makeOnDone();
|
||||
const result = await callOnboarding(fn, makeContext(), 'status');
|
||||
if (!React.isValidElement(result)) throw new Error('expected element');
|
||||
const el = result as React.ReactElement<{
|
||||
theme: string;
|
||||
hasCompletedOnboarding: boolean;
|
||||
lastOnboardingVersion: string;
|
||||
}>;
|
||||
expect(el.props.theme).toBe('light');
|
||||
expect(el.props.hasCompletedOnboarding).toBe(true);
|
||||
expect(el.props.lastOnboardingVersion).toBe('1.2.3');
|
||||
});
|
||||
|
||||
test('theme JSX wires onDone callback through ThemeSubcommand props', async () => {
|
||||
const { fn } = makeOnDone();
|
||||
const result = await callOnboarding(fn, makeContext(), 'theme');
|
||||
if (!React.isValidElement(result)) throw new Error('expected element');
|
||||
const el = result as React.ReactElement<{ onDone: (msg: string) => void }>;
|
||||
expect(typeof el.props.onDone).toBe('function');
|
||||
});
|
||||
|
||||
test('rendering ThemeSubcommand executes its body once', () => {
|
||||
// Pull the ThemeSubcommand render path through React.createElement so its
|
||||
// body (useTheme + ThemePicker JSX) executes under coverage.
|
||||
const result = callOnboarding(() => undefined, makeContext(), 'theme');
|
||||
return result.then(node => {
|
||||
if (!React.isValidElement(node)) throw new Error('not element');
|
||||
// Render the inner element by invoking its component function once.
|
||||
const Comp = (node as React.ReactElement).type as (p: unknown) => React.ReactNode;
|
||||
const rendered = Comp((node as React.ReactElement).props);
|
||||
expect(rendered).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
test('rendering StatusView executes its body once', async () => {
|
||||
const { fn } = makeOnDone();
|
||||
const result = await callOnboarding(fn, makeContext(), 'status');
|
||||
if (!React.isValidElement(result)) throw new Error('not element');
|
||||
const Comp = (result as React.ReactElement).type as (p: unknown) => React.ReactNode;
|
||||
const rendered = Comp((result as React.ReactElement).props);
|
||||
expect(rendered).toBeDefined();
|
||||
});
|
||||
|
||||
test('unknown subcommand reports error and does not mutate config', async () => {
|
||||
fakeGlobalConfig.hasCompletedOnboarding = true;
|
||||
const { fn, calls } = makeOnDone();
|
||||
const result = await callOnboarding(fn, makeContext(), 'bogus');
|
||||
expect(result).toBeNull();
|
||||
expect(calls[0]?.msg).toContain('Unknown');
|
||||
expect(calls[0]?.msg).toContain('bogus');
|
||||
expect(fakeGlobalConfig.hasCompletedOnboarding).toBe(true);
|
||||
});
|
||||
|
||||
test('every invocation logs a tengu_onboarding_step event', async () => {
|
||||
const { fn } = makeOnDone();
|
||||
for (const arg of ['full', 'theme', 'trust', 'model', 'mcp', 'status']) {
|
||||
loggedEvents.length = 0;
|
||||
await callOnboarding(fn, makeContext(), arg);
|
||||
expect(loggedEvents.find(e => e.name === 'tengu_onboarding_step')).toBeDefined();
|
||||
}
|
||||
});
|
||||
});
|
||||
3
src/commands/onboarding/index.d.ts
vendored
3
src/commands/onboarding/index.d.ts
vendored
@@ -1,3 +0,0 @@
|
||||
import type { Command } from '../../types/command.js'
|
||||
declare const _default: Command
|
||||
export default _default
|
||||
@@ -1 +0,0 @@
|
||||
export default { isEnabled: () => false, isHidden: true, name: 'stub' }
|
||||
30
src/commands/onboarding/index.ts
Normal file
30
src/commands/onboarding/index.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import type { Command } from '../../types/command.js'
|
||||
|
||||
// Subcommands supported by `/onboarding`.
|
||||
// - (no args) | full — re-run the complete first-run flow
|
||||
// - theme — re-pick the terminal theme
|
||||
// - trust — re-confirm the workspace trust dialog
|
||||
// - model — open the model picker (delegates to /model)
|
||||
// - mcp — show MCP server setup instructions
|
||||
// - status — print current onboarding state
|
||||
//
|
||||
// `/onboarding` exists in official v2.1.123 (string + telemetry confirmed:
|
||||
// `tengu_onboarding_step`, `hasCompletedOnboarding`, `lastOnboardingVersion`).
|
||||
// We expose the user-facing entry point so subscribers can re-run any step.
|
||||
const onboarding: Command = {
|
||||
type: 'local-jsx',
|
||||
name: 'onboarding',
|
||||
description: 'Re-run the first-run setup (theme, trust, model, MCP)',
|
||||
argumentHint: '[full|theme|trust|model|mcp|status]',
|
||||
isEnabled: () => true,
|
||||
isHidden: false,
|
||||
bridgeSafe: false,
|
||||
getBridgeInvocationError: () =>
|
||||
'onboarding requires the local interactive UI and is not bridge-safe',
|
||||
load: async () => {
|
||||
const m = await import('./launchOnboarding.js')
|
||||
return { call: m.callOnboarding }
|
||||
},
|
||||
}
|
||||
|
||||
export default onboarding
|
||||
190
src/commands/onboarding/launchOnboarding.tsx
Normal file
190
src/commands/onboarding/launchOnboarding.tsx
Normal file
@@ -0,0 +1,190 @@
|
||||
import * as React from 'react';
|
||||
import { Box, Pane, Text, useTheme } from '@anthropic/ink';
|
||||
import {
|
||||
type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
||||
logEvent,
|
||||
} from '../../services/analytics/index.js';
|
||||
import type { LocalJSXCommandCall } from '../../types/command.js';
|
||||
import { ThemePicker } from '../../components/ThemePicker.js';
|
||||
import { getGlobalConfig, saveCurrentProjectConfig, saveGlobalConfig } from '../../utils/config.js';
|
||||
import type { ThemeSetting } from '../../utils/theme.js';
|
||||
|
||||
/**
|
||||
* /onboarding [subcommand]
|
||||
*
|
||||
* User-facing slash command that re-runs the first-run setup flow. The
|
||||
* official v2.1.123 binary advertises `/onboarding` and emits
|
||||
* `tengu_onboarding_step` telemetry; this command exposes a clean entry
|
||||
* point for re-running individual steps after initial setup.
|
||||
*
|
||||
* Subcommands:
|
||||
* (none) | full | reset — clear `hasCompletedOnboarding` so the next
|
||||
* REPL launch re-runs the full flow, then exit
|
||||
* with instructions.
|
||||
* theme — render the theme picker inline.
|
||||
* trust — clear the workspace trust acceptance and
|
||||
* instruct the user to restart.
|
||||
* model — defer to /model (cannot mid-call suspend
|
||||
* into a separate command's Ink picker; print
|
||||
* instructions instead).
|
||||
* mcp — print MCP setup hints (delegates to /mcp).
|
||||
* status — show current onboarding state (theme,
|
||||
* completion flag, trust, last version).
|
||||
*/
|
||||
export type OnboardingSubcommand = 'full' | 'theme' | 'trust' | 'model' | 'mcp' | 'status';
|
||||
|
||||
const SUBCOMMANDS: ReadonlySet<OnboardingSubcommand> = new Set(['full', 'theme', 'trust', 'model', 'mcp', 'status']);
|
||||
|
||||
function meta(s: string): AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS {
|
||||
return s as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS;
|
||||
}
|
||||
|
||||
export function parseSubcommand(args: string): {
|
||||
sub: OnboardingSubcommand;
|
||||
unknownArg?: string;
|
||||
} {
|
||||
const trimmed = args.trim().toLowerCase();
|
||||
if (trimmed === '' || trimmed === 'reset') {
|
||||
return { sub: 'full' };
|
||||
}
|
||||
if (SUBCOMMANDS.has(trimmed as OnboardingSubcommand)) {
|
||||
return { sub: trimmed as OnboardingSubcommand };
|
||||
}
|
||||
return { sub: 'full', unknownArg: trimmed };
|
||||
}
|
||||
|
||||
function ThemeSubcommand({ onDone }: { onDone: (msg: string) => void }): React.ReactNode {
|
||||
const [, setTheme] = useTheme();
|
||||
return (
|
||||
<Pane color="permission">
|
||||
<ThemePicker
|
||||
onThemeSelect={(setting: ThemeSetting) => {
|
||||
setTheme(setting);
|
||||
logEvent('tengu_onboarding_step', { stepId: meta('theme') });
|
||||
onDone(`Theme set to ${setting}.`);
|
||||
}}
|
||||
onCancel={() => onDone('Theme picker dismissed.')}
|
||||
skipExitHandling={true}
|
||||
/>
|
||||
</Pane>
|
||||
);
|
||||
}
|
||||
|
||||
function StatusView({
|
||||
theme,
|
||||
hasCompletedOnboarding,
|
||||
lastOnboardingVersion,
|
||||
}: {
|
||||
theme: string;
|
||||
hasCompletedOnboarding: boolean;
|
||||
lastOnboardingVersion: string;
|
||||
}): React.ReactNode {
|
||||
return (
|
||||
<Box flexDirection="column" paddingLeft={1}>
|
||||
<Text bold>Onboarding status</Text>
|
||||
<Text>
|
||||
- Theme: <Text bold>{theme}</Text>
|
||||
</Text>
|
||||
<Text>
|
||||
- Onboarding completed:{' '}
|
||||
<Text bold color={hasCompletedOnboarding ? 'success' : 'warning'}>
|
||||
{hasCompletedOnboarding ? 'yes' : 'no'}
|
||||
</Text>
|
||||
</Text>
|
||||
<Text>
|
||||
- Last onboarding version: <Text bold>{lastOnboardingVersion}</Text>
|
||||
</Text>
|
||||
<Text dimColor>
|
||||
Run /onboarding (no args) to re-run the full flow, or /onboarding theme | trust | model | mcp for a specific
|
||||
step.
|
||||
</Text>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
export const callOnboarding: LocalJSXCommandCall = async (onDone, _context, args) => {
|
||||
const { sub, unknownArg } = parseSubcommand(args);
|
||||
logEvent('tengu_onboarding_step', { stepId: meta(`slash_${sub}`) });
|
||||
|
||||
if (unknownArg !== undefined) {
|
||||
onDone(
|
||||
`Unknown /onboarding subcommand: \`${unknownArg}\`.\n` + `Valid: full | theme | trust | model | mcp | status`,
|
||||
{ display: 'system' },
|
||||
);
|
||||
return null;
|
||||
}
|
||||
|
||||
if (sub === 'theme') {
|
||||
return <ThemeSubcommand onDone={msg => onDone(msg)} />;
|
||||
}
|
||||
|
||||
if (sub === 'trust') {
|
||||
saveCurrentProjectConfig(current => ({
|
||||
...current,
|
||||
hasTrustDialogAccepted: false,
|
||||
}));
|
||||
onDone(
|
||||
'Workspace trust cleared for the current project. ' + 'The trust dialog will appear on the next `claude` launch.',
|
||||
{ display: 'system' },
|
||||
);
|
||||
return null;
|
||||
}
|
||||
|
||||
if (sub === 'model') {
|
||||
onDone(
|
||||
'Run `/model` to pick the AI model. ' +
|
||||
'Onboarding does not own the model picker; this entry exists for ' +
|
||||
'discoverability only.',
|
||||
{ display: 'system' },
|
||||
);
|
||||
return null;
|
||||
}
|
||||
|
||||
if (sub === 'mcp') {
|
||||
onDone(
|
||||
'MCP server setup:\n' +
|
||||
' - `/mcp` — list configured MCP servers\n' +
|
||||
' - `claude mcp add <name> <command>` — add a server (in your shell)\n' +
|
||||
' - `claude mcp remove <name>` — remove a server\n' +
|
||||
'Servers also load from `.mcp.json` in the workspace and from ' +
|
||||
'`~/.claude.json` globally.',
|
||||
{ display: 'system' },
|
||||
);
|
||||
return null;
|
||||
}
|
||||
|
||||
if (sub === 'status') {
|
||||
const cfg = getGlobalConfig();
|
||||
return (
|
||||
<StatusView
|
||||
theme={cfg.theme ?? '(unset)'}
|
||||
hasCompletedOnboarding={cfg.hasCompletedOnboarding === true}
|
||||
lastOnboardingVersion={cfg.lastOnboardingVersion ?? '(unset)'}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// sub === 'full'
|
||||
// Clearing `hasCompletedOnboarding` causes `showSetupScreens()` (in
|
||||
// src/interactiveHelpers.tsx) to render the full Onboarding component
|
||||
// on the next launch. We cannot render <Onboarding /> mid-REPL because
|
||||
// it owns terminal-setup detection, OAuth flow, and final redirect to
|
||||
// the prompt — not safe to mount inside an active REPL session.
|
||||
saveGlobalConfig(current => ({
|
||||
...current,
|
||||
hasCompletedOnboarding: false,
|
||||
}));
|
||||
onDone(
|
||||
'Onboarding flag cleared. The full first-run setup ' +
|
||||
'(theme, OAuth/API key, security notes, terminal-setup) ' +
|
||||
'will run on the next `claude` launch.\n\n' +
|
||||
'For individual steps in this session, use:\n' +
|
||||
' /onboarding theme — re-pick theme inline\n' +
|
||||
' /onboarding trust — re-confirm workspace trust on next launch\n' +
|
||||
' /onboarding model — open /model picker\n' +
|
||||
' /onboarding mcp — show MCP setup hints\n' +
|
||||
' /onboarding status — show current onboarding state',
|
||||
{ display: 'system' },
|
||||
);
|
||||
return null;
|
||||
};
|
||||
638
src/commands/perf-issue/__tests__/perf-issue.test.ts
Normal file
638
src/commands/perf-issue/__tests__/perf-issue.test.ts
Normal file
@@ -0,0 +1,638 @@
|
||||
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
|
||||
|
||||
beforeEach(() => {
|
||||
tmpDir = mkdtempSync(join(tmpdir(), 'perf-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
|
||||
})
|
||||
|
||||
describe('perf-issue command', () => {
|
||||
test('command has correct name and type', async () => {
|
||||
const mod = await import('../index.js')
|
||||
const cmd = mod.default
|
||||
expect(cmd.name).toBe('perf-issue')
|
||||
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('writes a perf report and returns path in message', 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('Perf snapshot written to')
|
||||
expect(result.value).toContain('perf-reports')
|
||||
}
|
||||
})
|
||||
|
||||
test('includes session info and memory in report file', async () => {
|
||||
const { readFileSync, readdirSync } = await import('node:fs')
|
||||
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)
|
||||
if (result.type === 'text') {
|
||||
// Extract the path from the result message
|
||||
const pathMatch = result.value.match(/\n\s+`?(\S+?\.md)`?/)
|
||||
if (pathMatch) {
|
||||
const reportContent = readFileSync(pathMatch[1], 'utf8')
|
||||
expect(reportContent).toContain('Snapshot')
|
||||
expect(reportContent).toContain('Memory')
|
||||
expect(reportContent).toContain('CPU')
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
test('handles missing log gracefully', async () => {
|
||||
// Without a log file it should still work
|
||||
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') {
|
||||
// Should still produce a report, even if log section shows "not found"
|
||||
expect(result.value).toContain('written to')
|
||||
}
|
||||
})
|
||||
|
||||
test('log with timestamps and tool_use/result pairs covers lines 109-148', 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 })
|
||||
|
||||
const now = Date.now()
|
||||
const logLines = [
|
||||
// Numeric timestamp (covers lines 109-110)
|
||||
JSON.stringify({
|
||||
role: 'user',
|
||||
content: 'hello',
|
||||
timestamp: now - 5000,
|
||||
usage: { input_tokens: 100 },
|
||||
}),
|
||||
// String ISO timestamp (covers lines 112-113)
|
||||
JSON.stringify({
|
||||
role: 'assistant',
|
||||
content: [
|
||||
{ type: 'tool_use', id: 'tool_abc', name: 'BashTool', input: {} },
|
||||
],
|
||||
timestamp: new Date(now - 3000).toISOString(),
|
||||
usage: { output_tokens: 50 },
|
||||
}),
|
||||
// tool_result matching tool_use (covers lines 138-148)
|
||||
JSON.stringify({
|
||||
role: 'user',
|
||||
content: [
|
||||
{
|
||||
type: 'tool_result',
|
||||
tool_use_id: 'tool_abc',
|
||||
content: 'ok',
|
||||
},
|
||||
],
|
||||
timestamp: now - 2000,
|
||||
}),
|
||||
]
|
||||
writeFileSync(
|
||||
join(projectsDir, `${getSessionId()}.jsonl`),
|
||||
logLines.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)
|
||||
expect(result.type).toBe('text')
|
||||
if (result.type === 'text') {
|
||||
expect(result.value).toContain('written to')
|
||||
}
|
||||
})
|
||||
|
||||
test('log exists but is malformed → parse error path (lines 154-156)', 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 })
|
||||
// Write a log file where readFileSync succeeds but split/parse fails.
|
||||
// Actually analyzeLog does try/catch per line, so the outer catch at 154-156
|
||||
// is triggered only if readFileSync itself throws — but existsSync already
|
||||
// checked. We simulate by writing a log file that will pass existsSync but
|
||||
// causes analyzeLog to throw at the readFileSync level: we can't do this
|
||||
// without mocking fs (which we must not do).
|
||||
//
|
||||
// Alternative: write a valid log and verify the normal path works.
|
||||
// The parse-error path (lines 154-156) is the catch for analyzeLog()
|
||||
// inside hasLog=true block. Since analyzeLog's per-line errors are caught
|
||||
// internally, the outer catch only fires if readFileSync itself throws
|
||||
// (TOCTOU race). This is functionally unreachable in tests.
|
||||
// This test confirms the happy path without parse errors.
|
||||
writeFileSync(
|
||||
join(projectsDir, `${getSessionId()}.jsonl`),
|
||||
JSON.stringify({
|
||||
role: 'user',
|
||||
content: 'hi',
|
||||
usage: { input_tokens: 5 },
|
||||
}) + '\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('written to')
|
||||
}
|
||||
})
|
||||
|
||||
test('includes token usage when log file exists with usage data', 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 })
|
||||
const logLines = [
|
||||
JSON.stringify({
|
||||
role: 'user',
|
||||
content: 'hello',
|
||||
usage: { input_tokens: 100 },
|
||||
}),
|
||||
JSON.stringify({
|
||||
role: 'assistant',
|
||||
content: [{ type: 'tool_use', id: 't1', name: 'BashTool', input: {} }],
|
||||
usage: { output_tokens: 50 },
|
||||
}),
|
||||
]
|
||||
writeFileSync(
|
||||
join(projectsDir, `${getSessionId()}.jsonl`),
|
||||
logLines.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)
|
||||
expect(result.type).toBe('text')
|
||||
if (result.type === 'text') {
|
||||
expect(result.value).toContain('written to')
|
||||
}
|
||||
})
|
||||
|
||||
test('--format=json produces a .json file with token fields', async () => {
|
||||
const { sanitizePath } = await import('../../../utils/path.js')
|
||||
const { getSessionId, getOriginalCwd } = await import(
|
||||
'../../../bootstrap/state.js'
|
||||
)
|
||||
const projectsDir = join(
|
||||
claudeDir,
|
||||
'projects',
|
||||
sanitizePath(getOriginalCwd()),
|
||||
)
|
||||
mkdirSync(projectsDir, { recursive: true })
|
||||
writeFileSync(
|
||||
join(projectsDir, `${getSessionId()}.jsonl`),
|
||||
JSON.stringify({
|
||||
role: 'user',
|
||||
content: 'hello',
|
||||
usage: { input_tokens: 42 },
|
||||
}) + '\n',
|
||||
)
|
||||
const mod = await import('../index.js')
|
||||
const loaded = await (
|
||||
mod.default as unknown as {
|
||||
load: () => Promise<{
|
||||
call: (
|
||||
a: string,
|
||||
ctx: never,
|
||||
) => Promise<{ type: string; value: string }>
|
||||
}>
|
||||
}
|
||||
).load()
|
||||
const result = await loaded.call('--format=json', {} as never)
|
||||
expect(result.type).toBe('text')
|
||||
if (result.type === 'text') {
|
||||
const pathMatch = result.value.match(/\n\s+`?(\S+?\.json)`?/)
|
||||
if (pathMatch) {
|
||||
const { readFileSync } = await import('node:fs')
|
||||
const content = readFileSync(pathMatch[1], 'utf8')
|
||||
const parsed = JSON.parse(content)
|
||||
expect(parsed).toHaveProperty('tokens')
|
||||
expect(parsed.tokens.input).toBe(42)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
test('--format=csv produces a .csv file with metric rows', async () => {
|
||||
const { sanitizePath } = await import('../../../utils/path.js')
|
||||
const { getSessionId, getOriginalCwd } = await import(
|
||||
'../../../bootstrap/state.js'
|
||||
)
|
||||
const projectsDir = join(
|
||||
claudeDir,
|
||||
'projects',
|
||||
sanitizePath(getOriginalCwd()),
|
||||
)
|
||||
mkdirSync(projectsDir, { recursive: true })
|
||||
writeFileSync(
|
||||
join(projectsDir, `${getSessionId()}.jsonl`),
|
||||
JSON.stringify({
|
||||
role: 'user',
|
||||
content: 'hello',
|
||||
usage: { output_tokens: 10 },
|
||||
}) + '\n',
|
||||
)
|
||||
const mod = await import('../index.js')
|
||||
const loaded = await (
|
||||
mod.default as unknown as {
|
||||
load: () => Promise<{
|
||||
call: (
|
||||
a: string,
|
||||
ctx: never,
|
||||
) => Promise<{ type: string; value: string }>
|
||||
}>
|
||||
}
|
||||
).load()
|
||||
const result = await loaded.call('--format=csv', {} as never)
|
||||
expect(result.type).toBe('text')
|
||||
if (result.type === 'text') {
|
||||
const pathMatch = result.value.match(/\n\s+`?(\S+?\.csv)`?/)
|
||||
if (pathMatch) {
|
||||
const { readFileSync } = await import('node:fs')
|
||||
const content = readFileSync(pathMatch[1], 'utf8')
|
||||
expect(content).toContain('metric,value')
|
||||
expect(content).toContain('output_tokens,10')
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
test('report includes estimated_cost_usd and cache_hit_rate sections', async () => {
|
||||
const { sanitizePath } = await import('../../../utils/path.js')
|
||||
const { getSessionId, getOriginalCwd } = await import(
|
||||
'../../../bootstrap/state.js'
|
||||
)
|
||||
const projectsDir = join(
|
||||
claudeDir,
|
||||
'projects',
|
||||
sanitizePath(getOriginalCwd()),
|
||||
)
|
||||
mkdirSync(projectsDir, { recursive: true })
|
||||
writeFileSync(
|
||||
join(projectsDir, `${getSessionId()}.jsonl`),
|
||||
JSON.stringify({
|
||||
role: 'user',
|
||||
usage: {
|
||||
input_tokens: 1000,
|
||||
output_tokens: 200,
|
||||
cache_creation_input_tokens: 100,
|
||||
cache_read_input_tokens: 400,
|
||||
},
|
||||
}) + '\n',
|
||||
)
|
||||
const mod = await import('../index.js')
|
||||
const loaded = await (
|
||||
mod.default as unknown as {
|
||||
load: () => Promise<{
|
||||
call: (
|
||||
a: string,
|
||||
ctx: never,
|
||||
) => Promise<{ type: string; value: string }>
|
||||
}>
|
||||
}
|
||||
).load()
|
||||
const result = await loaded.call('', {} as never)
|
||||
if (result.type === 'text') {
|
||||
const pathMatch = result.value.match(/\n\s+`?(\S+?\.md)`?/)
|
||||
if (pathMatch) {
|
||||
const { readFileSync } = await import('node:fs')
|
||||
const content = readFileSync(pathMatch[1], 'utf8')
|
||||
expect(content).toContain('estimated_usd')
|
||||
expect(content).toContain('cache_hit_rate')
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// ── H1 regression: tool durations must use log timestamps, not Date.now() ──
|
||||
test('H1: tool durations are computed from log entry timestamps, not parse-time Date.now()', 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 })
|
||||
|
||||
const t0 = 1_000_000_000_000 // fixed epoch ms
|
||||
const toolUseEntry = JSON.stringify({
|
||||
role: 'assistant',
|
||||
content: [
|
||||
{ type: 'tool_use', id: 'id_reg1', name: 'BashTool', input: {} },
|
||||
],
|
||||
timestamp: t0,
|
||||
usage: { output_tokens: 10 },
|
||||
})
|
||||
const toolResultEntry = JSON.stringify({
|
||||
role: 'user',
|
||||
content: [{ type: 'tool_result', tool_use_id: 'id_reg1', content: 'ok' }],
|
||||
// 3 seconds after tool_use
|
||||
timestamp: t0 + 3000,
|
||||
})
|
||||
|
||||
writeFileSync(
|
||||
join(projectsDir, `${getSessionId()}.jsonl`),
|
||||
[toolUseEntry, toolResultEntry].join('\n') + '\n',
|
||||
)
|
||||
|
||||
const mod = await import('../index.js')
|
||||
const loaded = await (
|
||||
mod.default as unknown as {
|
||||
load: () => Promise<{
|
||||
call: (
|
||||
a: string,
|
||||
ctx: never,
|
||||
) => Promise<{ type: string; value: string }>
|
||||
}>
|
||||
}
|
||||
).load()
|
||||
const result = await loaded.call('--format=json', {} as never)
|
||||
expect(result.type).toBe('text')
|
||||
if (result.type === 'text') {
|
||||
const pathMatch = result.value.match(/\n\s+`?(\S+?\.json)`?/)
|
||||
if (pathMatch) {
|
||||
const { readFileSync } = await import('node:fs')
|
||||
const parsed = JSON.parse(readFileSync(pathMatch[1], 'utf8'))
|
||||
// BashTool avg should be ~3000ms (from timestamps), not <1ms (from Date.now())
|
||||
const avgMs = parsed.tool_avg_ms?.BashTool
|
||||
expect(typeof avgMs).toBe('number')
|
||||
// Must be close to 3000ms (±500ms tolerance for CI variability)
|
||||
expect(avgMs).toBeGreaterThan(2000)
|
||||
expect(avgMs).toBeLessThan(4000)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// ── H2 regression: per-model cost lookup, unknown model → null ──
|
||||
test('H2: known model produces cost estimate; unknown model produces null', 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 })
|
||||
|
||||
// Write a log with a known model field
|
||||
writeFileSync(
|
||||
join(projectsDir, `${getSessionId()}.jsonl`),
|
||||
JSON.stringify({
|
||||
role: 'assistant',
|
||||
model: 'claude-sonnet-4-20260401',
|
||||
content: [],
|
||||
usage: { input_tokens: 1000, output_tokens: 200 },
|
||||
}) + '\n',
|
||||
)
|
||||
|
||||
const mod = await import('../index.js')
|
||||
const loaded = await (
|
||||
mod.default as unknown as {
|
||||
load: () => Promise<{
|
||||
call: (
|
||||
a: string,
|
||||
ctx: never,
|
||||
) => Promise<{ type: string; value: string }>
|
||||
}>
|
||||
}
|
||||
).load()
|
||||
const result = await loaded.call('--format=json', {} as never)
|
||||
expect(result.type).toBe('text')
|
||||
if (result.type === 'text') {
|
||||
const pathMatch = result.value.match(/\n\s+`?(\S+?\.json)`?/)
|
||||
if (pathMatch) {
|
||||
const { readFileSync } = await import('node:fs')
|
||||
const parsed = JSON.parse(readFileSync(pathMatch[1], 'utf8'))
|
||||
// Known model → numeric cost
|
||||
expect(typeof parsed.estimated_cost_usd).toBe('number')
|
||||
expect(parsed.estimated_cost_usd).toBeGreaterThan(0)
|
||||
expect(parsed.detected_model).toBe('claude-sonnet-4-20260401')
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
test('H2: unrecognized model produces null estimated_cost_usd in JSON', 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: 'assistant',
|
||||
model: 'some-future-unknown-model-99',
|
||||
content: [],
|
||||
usage: { input_tokens: 500 },
|
||||
}) + '\n',
|
||||
)
|
||||
|
||||
const mod = await import('../index.js')
|
||||
const loaded = await (
|
||||
mod.default as unknown as {
|
||||
load: () => Promise<{
|
||||
call: (
|
||||
a: string,
|
||||
ctx: never,
|
||||
) => Promise<{ type: string; value: string }>
|
||||
}>
|
||||
}
|
||||
).load()
|
||||
const result = await loaded.call('--format=json', {} as never)
|
||||
if (result.type === 'text') {
|
||||
const pathMatch = result.value.match(/\n\s+`?(\S+?\.json)`?/)
|
||||
if (pathMatch) {
|
||||
const { readFileSync } = await import('node:fs')
|
||||
const parsed = JSON.parse(readFileSync(pathMatch[1], 'utf8'))
|
||||
expect(parsed.estimated_cost_usd).toBeNull()
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// ── M6 regression: error messages must be sanitized (no absolute home path) ──
|
||||
test('M6: error messages do not expose absolute home dir paths', async () => {
|
||||
const { homedir } = await import('node:os')
|
||||
const home = homedir()
|
||||
// Write an invalid perf report dir to force writeFileSync to fail
|
||||
// by pointing CLAUDE_CONFIG_DIR to a file (not a directory).
|
||||
const filePath = join(tmpDir, 'not-a-dir')
|
||||
const { writeFileSync: wfs } = await import('node:fs')
|
||||
wfs(filePath, 'block', 'utf8')
|
||||
// Override CLAUDE_CONFIG_DIR to point to a file so mkdirSync inside call() fails
|
||||
process.env.CLAUDE_CONFIG_DIR = filePath
|
||||
|
||||
const mod = await import('../index.js')
|
||||
const loaded = await (
|
||||
mod.default as unknown as {
|
||||
load: () => Promise<{
|
||||
call: (
|
||||
a: string,
|
||||
ctx: never,
|
||||
) => Promise<{ type: string; value: string }>
|
||||
}>
|
||||
}
|
||||
).load()
|
||||
const result = await loaded.call('', {} as never)
|
||||
|
||||
// Restore CLAUDE_CONFIG_DIR so subsequent tests are not affected
|
||||
process.env.CLAUDE_CONFIG_DIR = claudeDir
|
||||
|
||||
if (result.type === 'text' && result.value.includes('Failed')) {
|
||||
// Must not contain the raw home directory path
|
||||
expect(result.value).not.toContain(home)
|
||||
// Must be at most 200 chars in the error portion
|
||||
const errPart = result.value.replace('Failed to write perf report: ', '')
|
||||
expect(errPart.length).toBeLessThanOrEqual(210) // +small overhead for the prefix chars
|
||||
}
|
||||
})
|
||||
|
||||
// ── M4 regression: --limit caps lines read ──
|
||||
test('M4: --limit N caps the number of log lines analyzed', 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 })
|
||||
|
||||
// Write 10 lines with usage
|
||||
const logLines = Array.from({ length: 10 }, (_, i) =>
|
||||
JSON.stringify({
|
||||
role: 'user',
|
||||
content: `msg ${i}`,
|
||||
usage: { input_tokens: 10 },
|
||||
}),
|
||||
)
|
||||
writeFileSync(
|
||||
join(projectsDir, `${getSessionId()}.jsonl`),
|
||||
logLines.join('\n') + '\n',
|
||||
)
|
||||
|
||||
const mod = await import('../index.js')
|
||||
const loaded = await (
|
||||
mod.default as unknown as {
|
||||
load: () => Promise<{
|
||||
call: (
|
||||
a: string,
|
||||
ctx: never,
|
||||
) => Promise<{ type: string; value: string }>
|
||||
}>
|
||||
}
|
||||
).load()
|
||||
// --limit 3 should only analyze last 3 lines (30 tokens)
|
||||
const result = await loaded.call('--format=json --limit 3', {} as never)
|
||||
if (result.type === 'text') {
|
||||
const pathMatch = result.value.match(/\n\s+`?(\S+?\.json)`?/)
|
||||
if (pathMatch) {
|
||||
const { readFileSync } = await import('node:fs')
|
||||
const parsed = JSON.parse(readFileSync(pathMatch[1], 'utf8'))
|
||||
// With --limit 3, only 3 lines × 10 tokens = 30 input tokens
|
||||
expect(parsed.tokens.input).toBe(30)
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -1 +0,0 @@
|
||||
export default { isEnabled: () => false, isHidden: true, name: 'stub' }
|
||||
570
src/commands/perf-issue/index.ts
Normal file
570
src/commands/perf-issue/index.ts
Normal file
@@ -0,0 +1,570 @@
|
||||
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'
|
||||
import { join } from 'node:path'
|
||||
import { homedir } from 'node:os'
|
||||
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'
|
||||
|
||||
/**
|
||||
* Cost rates in USD per 1M tokens, keyed by model ID prefix.
|
||||
* Rates sourced from Anthropic pricing page (2026-04).
|
||||
* Unrecognized models produce a '~$ unknown' label instead of a stale estimate.
|
||||
*/
|
||||
const MODEL_COST_RATES: Record<
|
||||
string,
|
||||
{ input: number; output: number; cache_creation: number; cache_read: number }
|
||||
> = {
|
||||
// Claude Sonnet 4.6 / claude-sonnet-4 series
|
||||
'claude-sonnet-4': {
|
||||
input: 3.0,
|
||||
output: 15.0,
|
||||
cache_creation: 3.75,
|
||||
cache_read: 0.3,
|
||||
},
|
||||
// Claude Opus 4.5 / claude-opus-4 series
|
||||
'claude-opus-4': {
|
||||
input: 15.0,
|
||||
output: 75.0,
|
||||
cache_creation: 18.75,
|
||||
cache_read: 1.5,
|
||||
},
|
||||
// Claude Haiku 4.5 / claude-haiku-4 series
|
||||
'claude-haiku-4': {
|
||||
input: 0.8,
|
||||
output: 4.0,
|
||||
cache_creation: 1.0,
|
||||
cache_read: 0.08,
|
||||
},
|
||||
// Claude 3.7 Sonnet
|
||||
'claude-3-7-sonnet': {
|
||||
input: 3.0,
|
||||
output: 15.0,
|
||||
cache_creation: 3.75,
|
||||
cache_read: 0.3,
|
||||
},
|
||||
// Claude 3.5 Sonnet
|
||||
'claude-3-5-sonnet': {
|
||||
input: 3.0,
|
||||
output: 15.0,
|
||||
cache_creation: 3.75,
|
||||
cache_read: 0.3,
|
||||
},
|
||||
// Claude 3.5 Haiku
|
||||
'claude-3-5-haiku': {
|
||||
input: 0.8,
|
||||
output: 4.0,
|
||||
cache_creation: 1.0,
|
||||
cache_read: 0.08,
|
||||
},
|
||||
// Claude 3 Opus
|
||||
'claude-3-opus': {
|
||||
input: 15.0,
|
||||
output: 75.0,
|
||||
cache_creation: 18.75,
|
||||
cache_read: 1.5,
|
||||
},
|
||||
}
|
||||
|
||||
type CostRates = {
|
||||
input: number
|
||||
output: number
|
||||
cache_creation: number
|
||||
cache_read: number
|
||||
}
|
||||
|
||||
function lookupCostRates(model: string | null | undefined): CostRates | null {
|
||||
if (!model) return null
|
||||
for (const [prefix, rates] of Object.entries(MODEL_COST_RATES)) {
|
||||
if (model.startsWith(prefix)) return rates
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* Sanitizes an error message before surfacing it to the user:
|
||||
* - Replaces the home directory path with "~" to avoid leaking absolute paths.
|
||||
* - Truncates to 200 characters to avoid leaking large stack traces or token fragments.
|
||||
*/
|
||||
function sanitizeErrorMessage(msg: string): string {
|
||||
const home = homedir()
|
||||
let sanitized = msg.replace(
|
||||
new RegExp(home.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'g'),
|
||||
'~',
|
||||
)
|
||||
if (sanitized.length > 200) sanitized = sanitized.slice(0, 200) + '…'
|
||||
return sanitized
|
||||
}
|
||||
|
||||
function getPerfReportDir(): string {
|
||||
return join(homedir(), '.claude', 'perf-reports')
|
||||
}
|
||||
|
||||
function getTranscriptPath(): string {
|
||||
const sessionId = getSessionId()
|
||||
const projectDir = getSessionProjectDir()
|
||||
if (projectDir) return join(projectDir, `${sessionId}.jsonl`)
|
||||
return join(
|
||||
getClaudeConfigHomeDir(),
|
||||
'projects',
|
||||
sanitizePath(getOriginalCwd()),
|
||||
`${sessionId}.jsonl`,
|
||||
)
|
||||
}
|
||||
|
||||
interface UsageTotals {
|
||||
input_tokens: number
|
||||
output_tokens: number
|
||||
cache_creation_input_tokens: number
|
||||
cache_read_input_tokens: number
|
||||
}
|
||||
|
||||
interface LogEntry {
|
||||
role?: string
|
||||
type?: string
|
||||
content?: unknown
|
||||
usage?: Record<string, number>
|
||||
timestamp?: string | number
|
||||
model?: string
|
||||
}
|
||||
|
||||
interface ToolUseBlock {
|
||||
type: 'tool_use'
|
||||
name?: string
|
||||
id?: string
|
||||
}
|
||||
|
||||
interface ToolResultBlock {
|
||||
type: 'tool_result'
|
||||
tool_use_id?: string
|
||||
}
|
||||
|
||||
interface ToolTiming {
|
||||
name: string
|
||||
/** Timestamp from the log entry (ms). null means no timestamp was present. */
|
||||
logTimestampMs: number | null
|
||||
durationMs?: number
|
||||
}
|
||||
|
||||
interface AnalyzedLog {
|
||||
usage: UsageTotals
|
||||
toolCounts: Record<string, number>
|
||||
/** Durations in ms computed from log timestamps. Only present when both
|
||||
* tool_use and tool_result entries carry a timestamp. */
|
||||
toolDurations: Record<string, number[]>
|
||||
turnCount: number
|
||||
messageCount: number
|
||||
cacheHitRate: number
|
||||
estimatedCostUsd: number | null
|
||||
/** Model detected from log (first assistant message with a model field). */
|
||||
detectedModel: string | null
|
||||
firstTimestampMs: number | null
|
||||
lastTimestampMs: number | null
|
||||
wallClockSeconds: number | null
|
||||
}
|
||||
|
||||
function parseTimestampMs(tsRaw: string | number | undefined): number | null {
|
||||
if (tsRaw === undefined) return null
|
||||
const tsMs =
|
||||
typeof tsRaw === 'number'
|
||||
? tsRaw
|
||||
: typeof tsRaw === 'string'
|
||||
? Date.parse(tsRaw)
|
||||
: null
|
||||
if (tsMs === null || Number.isNaN(tsMs)) return null
|
||||
return tsMs
|
||||
}
|
||||
|
||||
/**
|
||||
* Default maximum number of JSONL lines to read from the log file.
|
||||
* Prevents OOM when session transcripts grow beyond hundreds of MB.
|
||||
* The last MAX_LOG_LINES lines are used so recent activity is always reflected.
|
||||
*/
|
||||
const MAX_LOG_LINES = 20_000
|
||||
|
||||
function analyzeLog(logPath: string, maxLines = MAX_LOG_LINES): AnalyzedLog {
|
||||
const usage: UsageTotals = {
|
||||
input_tokens: 0,
|
||||
output_tokens: 0,
|
||||
cache_creation_input_tokens: 0,
|
||||
cache_read_input_tokens: 0,
|
||||
}
|
||||
const toolCounts: Record<string, number> = {}
|
||||
const toolDurations: Record<string, number[]> = {}
|
||||
const pendingToolUses = new Map<string, ToolTiming>()
|
||||
let turnCount = 0
|
||||
let messageCount = 0
|
||||
let firstTimestampMs: number | null = null
|
||||
let lastTimestampMs: number | null = null
|
||||
let detectedModel: string | null = null
|
||||
|
||||
const allLines = readFileSync(logPath, 'utf8')
|
||||
.trim()
|
||||
.split('\n')
|
||||
.filter(Boolean)
|
||||
// Apply line cap: use the last maxLines entries so recent turns are always included.
|
||||
const lines =
|
||||
allLines.length > maxLines ? allLines.slice(-maxLines) : allLines
|
||||
|
||||
for (const line of lines) {
|
||||
try {
|
||||
const entry = JSON.parse(line) as LogEntry
|
||||
messageCount++
|
||||
|
||||
if (entry.role === 'user') turnCount++
|
||||
|
||||
// Capture first observed model name from any entry
|
||||
if (entry.model && detectedModel === null) {
|
||||
detectedModel = entry.model
|
||||
}
|
||||
|
||||
// Track wall-clock window from log entry timestamps
|
||||
const entryTsMs = parseTimestampMs(entry.timestamp)
|
||||
if (entryTsMs !== null) {
|
||||
if (firstTimestampMs === null) firstTimestampMs = entryTsMs
|
||||
lastTimestampMs = entryTsMs
|
||||
}
|
||||
|
||||
if (entry.usage) {
|
||||
for (const key of Object.keys(usage) as Array<keyof UsageTotals>) {
|
||||
const val = entry.usage[key]
|
||||
if (typeof val === 'number') usage[key] += val
|
||||
}
|
||||
}
|
||||
|
||||
if (Array.isArray(entry.content)) {
|
||||
for (const block of entry.content as Array<Record<string, unknown>>) {
|
||||
if (block.type === 'tool_use') {
|
||||
const b = block as unknown as ToolUseBlock
|
||||
const name = b.name ?? 'unknown'
|
||||
toolCounts[name] = (toolCounts[name] ?? 0) + 1
|
||||
if (b.id) {
|
||||
// Record the log-entry timestamp for this tool_use; null if absent.
|
||||
pendingToolUses.set(b.id, { name, logTimestampMs: entryTsMs })
|
||||
}
|
||||
} else if (block.type === 'tool_result') {
|
||||
const b = block as unknown as ToolResultBlock
|
||||
if (b.tool_use_id) {
|
||||
const pending = pendingToolUses.get(b.tool_use_id)
|
||||
if (pending) {
|
||||
// Only record duration when both endpoints have a real timestamp.
|
||||
if (pending.logTimestampMs !== null && entryTsMs !== null) {
|
||||
const durationMs = entryTsMs - pending.logTimestampMs
|
||||
toolDurations[pending.name] =
|
||||
toolDurations[pending.name] ?? []
|
||||
toolDurations[pending.name].push(durationMs)
|
||||
}
|
||||
pendingToolUses.delete(b.tool_use_id)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// skip malformed
|
||||
}
|
||||
}
|
||||
|
||||
// Cache hit rate: fraction of cache-related tokens that were hits (not creation)
|
||||
const cacheTotal =
|
||||
usage.cache_creation_input_tokens + usage.cache_read_input_tokens
|
||||
const cacheHitRate =
|
||||
cacheTotal > 0 ? usage.cache_read_input_tokens / cacheTotal : 0
|
||||
|
||||
// Cost estimate — only if we can look up rates for the detected model.
|
||||
const rates = lookupCostRates(detectedModel)
|
||||
const estimatedCostUsd = rates
|
||||
? (usage.input_tokens / 1_000_000) * rates.input +
|
||||
(usage.output_tokens / 1_000_000) * rates.output +
|
||||
(usage.cache_creation_input_tokens / 1_000_000) * rates.cache_creation +
|
||||
(usage.cache_read_input_tokens / 1_000_000) * rates.cache_read
|
||||
: null
|
||||
|
||||
const wallClockSeconds =
|
||||
firstTimestampMs !== null && lastTimestampMs !== null
|
||||
? (lastTimestampMs - firstTimestampMs) / 1000
|
||||
: null
|
||||
|
||||
return {
|
||||
usage,
|
||||
toolCounts,
|
||||
toolDurations,
|
||||
turnCount,
|
||||
messageCount,
|
||||
cacheHitRate,
|
||||
estimatedCostUsd,
|
||||
detectedModel,
|
||||
firstTimestampMs,
|
||||
lastTimestampMs,
|
||||
wallClockSeconds,
|
||||
}
|
||||
}
|
||||
|
||||
function top10Tools(toolCounts: Record<string, number>): string[] {
|
||||
return Object.entries(toolCounts)
|
||||
.sort((a, b) => b[1] - a[1])
|
||||
.slice(0, 10)
|
||||
.map(([name, count]) => ` ${name.padEnd(40)} ${count}`)
|
||||
}
|
||||
|
||||
function avgMs(values: number[]): number {
|
||||
if (values.length === 0) return 0
|
||||
return values.reduce((a, b) => a + b, 0) / values.length
|
||||
}
|
||||
|
||||
function formatReportMarkdown(
|
||||
sessionId: string,
|
||||
logPath: string,
|
||||
analyzed: AnalyzedLog,
|
||||
): string {
|
||||
const {
|
||||
usage,
|
||||
toolCounts,
|
||||
toolDurations,
|
||||
turnCount,
|
||||
messageCount,
|
||||
cacheHitRate,
|
||||
estimatedCostUsd,
|
||||
detectedModel,
|
||||
wallClockSeconds,
|
||||
} = analyzed
|
||||
const m = process.memoryUsage()
|
||||
const cpu = process.cpuUsage()
|
||||
const totalTokens =
|
||||
usage.input_tokens +
|
||||
usage.output_tokens +
|
||||
usage.cache_creation_input_tokens +
|
||||
usage.cache_read_input_tokens
|
||||
const toolLines = top10Tools(toolCounts)
|
||||
|
||||
const toolAvgLines = Object.entries(toolDurations)
|
||||
.sort((a, b) => b[1].length - a[1].length)
|
||||
.slice(0, 10)
|
||||
.map(
|
||||
([name, durs]) =>
|
||||
` ${name.padEnd(40)} avg ${avgMs(durs).toFixed(0)} ms (${durs.length} calls)`,
|
||||
)
|
||||
|
||||
return [
|
||||
'# Claude Code Performance Snapshot',
|
||||
'',
|
||||
`- timestamp: ${new Date().toISOString()}`,
|
||||
`- session: ${sessionId}`,
|
||||
`- pid: ${process.pid}`,
|
||||
`- platform: ${process.platform} ${process.arch}`,
|
||||
`- bun: ${typeof Bun !== 'undefined' ? Bun.version : 'n/a'}`,
|
||||
`- node: ${process.version}`,
|
||||
`- uptime: ${process.uptime().toFixed(1)}s`,
|
||||
'',
|
||||
'## Memory',
|
||||
`- rss: ${m.rss}`,
|
||||
`- heap used: ${m.heapUsed}`,
|
||||
`- heap total: ${m.heapTotal}`,
|
||||
`- external: ${m.external}`,
|
||||
`- array buffers: ${m.arrayBuffers ?? 0}`,
|
||||
'',
|
||||
'## CPU (process.cpuUsage, microseconds)',
|
||||
`- user: ${cpu.user}`,
|
||||
`- system: ${cpu.system}`,
|
||||
'',
|
||||
'## Session Token Usage',
|
||||
`- total_tokens: ${totalTokens.toLocaleString()}`,
|
||||
`- input_tokens: ${usage.input_tokens.toLocaleString()}`,
|
||||
`- output_tokens: ${usage.output_tokens.toLocaleString()}`,
|
||||
`- cache_creation: ${usage.cache_creation_input_tokens.toLocaleString()}`,
|
||||
`- cache_read: ${usage.cache_read_input_tokens.toLocaleString()}`,
|
||||
`- turns (user messages): ${turnCount}`,
|
||||
`- total log entries: ${messageCount}`,
|
||||
wallClockSeconds !== null
|
||||
? `- wall_clock_seconds: ${wallClockSeconds.toFixed(1)}`
|
||||
: '',
|
||||
'',
|
||||
'## Cost Estimate (approximate)',
|
||||
detectedModel
|
||||
? `- model: ${detectedModel}`
|
||||
: '- model: (unknown — not present in log)',
|
||||
estimatedCostUsd !== null
|
||||
? `- estimated_usd: $${estimatedCostUsd.toFixed(4)}`
|
||||
: '- estimated_usd: ~$ unknown (unrecognized model)',
|
||||
`- cache_hit_rate: ${(cacheHitRate * 100).toFixed(1)}%`,
|
||||
'',
|
||||
'## Tool Call Counts (top 10)',
|
||||
toolLines.length > 0 ? toolLines.join('\n') : ' (no tool calls)',
|
||||
'',
|
||||
'## Tool Average Execution Time (top 10 by call count)',
|
||||
toolAvgLines.length > 0
|
||||
? toolAvgLines.join('\n')
|
||||
: ' (no timing data — tool_result/tool_use pairs not found)',
|
||||
'',
|
||||
'## Notes',
|
||||
'',
|
||||
'Add a description of what you were doing when the perf issue surfaced:',
|
||||
'',
|
||||
'- ___',
|
||||
'',
|
||||
"_(File this report in your repo's issue tracker. No network call was made._",
|
||||
'_The fork does not transmit perf reports to Anthropic.)_',
|
||||
]
|
||||
.filter(line => line !== '')
|
||||
.join('\n')
|
||||
}
|
||||
|
||||
function formatReportJSON(sessionId: string, analyzed: AnalyzedLog): string {
|
||||
const m = process.memoryUsage()
|
||||
const cpu = process.cpuUsage()
|
||||
const totalTokens =
|
||||
analyzed.usage.input_tokens +
|
||||
analyzed.usage.output_tokens +
|
||||
analyzed.usage.cache_creation_input_tokens +
|
||||
analyzed.usage.cache_read_input_tokens
|
||||
|
||||
return JSON.stringify(
|
||||
{
|
||||
timestamp: new Date().toISOString(),
|
||||
session: sessionId,
|
||||
pid: process.pid,
|
||||
platform: process.platform,
|
||||
arch: process.arch,
|
||||
uptime: process.uptime(),
|
||||
memory: { ...m },
|
||||
cpu: { ...cpu },
|
||||
tokens: {
|
||||
total: totalTokens,
|
||||
input: analyzed.usage.input_tokens,
|
||||
output: analyzed.usage.output_tokens,
|
||||
cache_creation: analyzed.usage.cache_creation_input_tokens,
|
||||
cache_read: analyzed.usage.cache_read_input_tokens,
|
||||
},
|
||||
turns: analyzed.turnCount,
|
||||
messages: analyzed.messageCount,
|
||||
cache_hit_rate: analyzed.cacheHitRate,
|
||||
detected_model: analyzed.detectedModel,
|
||||
estimated_cost_usd: analyzed.estimatedCostUsd,
|
||||
wall_clock_seconds: analyzed.wallClockSeconds,
|
||||
tool_counts: analyzed.toolCounts,
|
||||
tool_avg_ms: Object.fromEntries(
|
||||
Object.entries(analyzed.toolDurations).map(([k, v]) => [k, avgMs(v)]),
|
||||
),
|
||||
},
|
||||
null,
|
||||
2,
|
||||
)
|
||||
}
|
||||
|
||||
function formatReportCSV(analyzed: AnalyzedLog): string {
|
||||
const rows: string[] = [
|
||||
'metric,value',
|
||||
`timestamp,${new Date().toISOString()}`,
|
||||
`input_tokens,${analyzed.usage.input_tokens}`,
|
||||
`output_tokens,${analyzed.usage.output_tokens}`,
|
||||
`cache_creation_tokens,${analyzed.usage.cache_creation_input_tokens}`,
|
||||
`cache_read_tokens,${analyzed.usage.cache_read_input_tokens}`,
|
||||
`turns,${analyzed.turnCount}`,
|
||||
`cache_hit_rate,${analyzed.cacheHitRate.toFixed(4)}`,
|
||||
`estimated_cost_usd,${analyzed.estimatedCostUsd !== null ? analyzed.estimatedCostUsd.toFixed(6) : 'unknown'}`,
|
||||
`wall_clock_seconds,${analyzed.wallClockSeconds ?? ''}`,
|
||||
...Object.entries(analyzed.toolCounts).map(
|
||||
([name, count]) => `tool_count_${name},${count}`,
|
||||
),
|
||||
]
|
||||
return rows.join('\n')
|
||||
}
|
||||
|
||||
const perfIssue: Command = {
|
||||
type: 'local',
|
||||
name: 'perf-issue',
|
||||
description:
|
||||
'Capture a performance + token-usage snapshot. Flags: --format=json|csv|md (default md)',
|
||||
isHidden: false,
|
||||
isEnabled: () => true,
|
||||
supportsNonInteractive: true,
|
||||
bridgeSafe: true,
|
||||
load: async () => ({
|
||||
call: async (args: string): Promise<LocalCommandResult> => {
|
||||
try {
|
||||
// Parse --format flag
|
||||
const formatMatch = args.match(/--format[= ](json|csv|md)/)
|
||||
const format: 'md' | 'json' | 'csv' = formatMatch
|
||||
? (formatMatch[1] as 'md' | 'json' | 'csv')
|
||||
: 'md'
|
||||
|
||||
// Parse --limit N (max JSONL lines to read; guards against OOM on large logs)
|
||||
const limitMatch = args.match(/--limit[= ](\d+)/)
|
||||
const lineLimit = limitMatch
|
||||
? Math.max(1, parseInt(limitMatch[1], 10))
|
||||
: MAX_LOG_LINES
|
||||
|
||||
const dir = getPerfReportDir()
|
||||
mkdirSync(dir, { recursive: true })
|
||||
const stamp = new Date().toISOString().replace(/[:.]/g, '-')
|
||||
const sessionId = getSessionId()
|
||||
const ext = format === 'json' ? 'json' : format === 'csv' ? 'csv' : 'md'
|
||||
const reportPath = join(
|
||||
dir,
|
||||
`perf-${stamp}-${sessionId.slice(0, 8)}.${ext}`,
|
||||
)
|
||||
|
||||
const logPath = getTranscriptPath()
|
||||
const hasLog = existsSync(logPath)
|
||||
|
||||
let analyzed: AnalyzedLog | null = null
|
||||
if (hasLog) {
|
||||
try {
|
||||
analyzed = analyzeLog(logPath, lineLimit)
|
||||
} catch {
|
||||
analyzed = null
|
||||
}
|
||||
}
|
||||
|
||||
// Build empty analyzed stats when log is unavailable
|
||||
const safeAnalyzed: AnalyzedLog = analyzed ?? {
|
||||
usage: {
|
||||
input_tokens: 0,
|
||||
output_tokens: 0,
|
||||
cache_creation_input_tokens: 0,
|
||||
cache_read_input_tokens: 0,
|
||||
},
|
||||
toolCounts: {},
|
||||
toolDurations: {},
|
||||
turnCount: 0,
|
||||
messageCount: 0,
|
||||
cacheHitRate: 0,
|
||||
estimatedCostUsd: null,
|
||||
detectedModel: null,
|
||||
firstTimestampMs: null,
|
||||
lastTimestampMs: null,
|
||||
wallClockSeconds: null,
|
||||
}
|
||||
|
||||
let reportContent: string
|
||||
if (format === 'json') {
|
||||
reportContent = formatReportJSON(sessionId, safeAnalyzed)
|
||||
} else if (format === 'csv') {
|
||||
reportContent = formatReportCSV(safeAnalyzed)
|
||||
} else {
|
||||
reportContent = formatReportMarkdown(sessionId, logPath, safeAnalyzed)
|
||||
if (!hasLog) {
|
||||
reportContent += `\n\n## Session Log\n(log not found at \`${logPath}\`)`
|
||||
}
|
||||
}
|
||||
|
||||
writeFileSync(reportPath, reportContent, 'utf8')
|
||||
return {
|
||||
type: 'text',
|
||||
value: `Perf snapshot written to:\n \`${reportPath}\`\n\nFormat: ${format}\nEdit it to add notes, then attach to your bug report.`,
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
const msg = sanitizeErrorMessage(
|
||||
err instanceof Error ? err.message : String(err),
|
||||
)
|
||||
return { type: 'text', value: `Failed to write perf report: ${msg}` }
|
||||
}
|
||||
},
|
||||
}),
|
||||
}
|
||||
|
||||
export default perfIssue
|
||||
177
src/commands/recap/__tests__/recap.test.ts
Normal file
177
src/commands/recap/__tests__/recap.test.ts
Normal file
@@ -0,0 +1,177 @@
|
||||
import { afterEach, beforeEach, describe, expect, mock, test } from 'bun:test'
|
||||
|
||||
// Mock bun:bundle before any imports that use feature()
|
||||
// Note: in the test environment AWAY_SUMMARY compile-time flag is false, so
|
||||
// isEnabled() will always return false regardless of the GrowthBook value.
|
||||
// We mock to true here to allow other feature-flagged code paths to be tested.
|
||||
mock.module('bun:bundle', () => ({
|
||||
feature: (_name: string) => true,
|
||||
}))
|
||||
|
||||
// Mock log/debug to avoid bootstrap side effects
|
||||
mock.module('src/utils/log.ts', () => ({
|
||||
logError: () => {},
|
||||
logInfo: () => {},
|
||||
logWarning: () => {},
|
||||
}))
|
||||
mock.module('src/utils/debug.ts', () => ({
|
||||
logForDebugging: () => {},
|
||||
isDebug: () => false,
|
||||
}))
|
||||
|
||||
// Mock settings to avoid filesystem side effects
|
||||
mock.module('src/utils/settings/settings.js', () => ({
|
||||
getCachedSettings: () => ({}),
|
||||
getSettings: async () => ({}),
|
||||
updateSettings: async () => {},
|
||||
}))
|
||||
|
||||
// Mock analytics (GrowthBook) — required for isEnabled()
|
||||
let gbValue = true
|
||||
mock.module('src/services/analytics/growthbook.js', () => ({
|
||||
getFeatureValue_CACHED_MAY_BE_STALE: (_key: string, defaultVal: unknown) =>
|
||||
gbValue ?? defaultVal,
|
||||
}))
|
||||
|
||||
// Mock the forkedAgent utility used by generateRecap
|
||||
let mockRecapResult: {
|
||||
kind: 'ok' | 'api-error' | 'no-turn' | 'aborted' | 'failed'
|
||||
text?: string
|
||||
} = { kind: 'ok', text: 'Working on fixing the auth bug. Next: run tests.' }
|
||||
|
||||
mock.module('src/commands/recap/generateRecap.js', () => ({
|
||||
generateRecap: async (_signal: AbortSignal) => mockRecapResult,
|
||||
}))
|
||||
|
||||
let recapCmd: any
|
||||
let callFn:
|
||||
| ((args: string, context: any) => Promise<{ type: string; value: string }>)
|
||||
| undefined
|
||||
|
||||
beforeEach(async () => {
|
||||
gbValue = true
|
||||
mockRecapResult = {
|
||||
kind: 'ok',
|
||||
text: 'Working on fixing the auth bug. Next: run tests.',
|
||||
}
|
||||
// Re-import to get fresh module
|
||||
const mod = await import('../index.js')
|
||||
recapCmd = mod.default
|
||||
const loaded = await recapCmd.load()
|
||||
callFn = loaded.call
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
recapCmd = undefined
|
||||
callFn = undefined
|
||||
})
|
||||
|
||||
// ── Metadata ──────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('recap command metadata', () => {
|
||||
test('has correct name', () => {
|
||||
expect(recapCmd.name).toBe('recap')
|
||||
})
|
||||
|
||||
test('has description mentioning recap/session', () => {
|
||||
expect(recapCmd.description).toBeTruthy()
|
||||
expect(typeof recapCmd.description).toBe('string')
|
||||
expect(recapCmd.description.length).toBeGreaterThan(5)
|
||||
})
|
||||
|
||||
test('type is local', () => {
|
||||
expect(recapCmd.type).toBe('local')
|
||||
})
|
||||
|
||||
test('supportsNonInteractive is false', () => {
|
||||
expect(recapCmd.supportsNonInteractive).toBe(false)
|
||||
})
|
||||
|
||||
test('has aliases including away and catchup', () => {
|
||||
expect(recapCmd.aliases).toBeDefined()
|
||||
expect(recapCmd.aliases).toContain('away')
|
||||
expect(recapCmd.aliases).toContain('catchup')
|
||||
})
|
||||
|
||||
test('isEnabled returns boolean', () => {
|
||||
// feature('AWAY_SUMMARY') is a compile-time constant; in the test env
|
||||
// it evaluates to false (flag not set), so isEnabled() returns false
|
||||
// regardless of GrowthBook. We verify it returns a boolean, not throws.
|
||||
const result = recapCmd.isEnabled()
|
||||
expect(typeof result).toBe('boolean')
|
||||
})
|
||||
|
||||
test('isEnabled returns false when GrowthBook flag is false', () => {
|
||||
// GrowthBook off → isEnabled must be false (belt-and-suspenders check
|
||||
// for when the feature flag is true in a real build)
|
||||
gbValue = false
|
||||
const result = recapCmd.isEnabled()
|
||||
expect(result).toBe(false)
|
||||
})
|
||||
|
||||
test('load() resolves to module with call function', async () => {
|
||||
const mod = await recapCmd.load()
|
||||
expect(typeof mod.call).toBe('function')
|
||||
})
|
||||
})
|
||||
|
||||
// ── Call behavior ─────────────────────────────────────────────────────────────
|
||||
|
||||
describe('recap command call()', () => {
|
||||
// Cast to any: test only needs abortController, not the full ToolUseContext shape
|
||||
const fakeContext: any = {
|
||||
abortController: new AbortController(),
|
||||
messages: [],
|
||||
options: { tools: [], mainLoopModel: 'claude-3-5-haiku-20241022' },
|
||||
}
|
||||
|
||||
test('returns text value on ok result', async () => {
|
||||
mockRecapResult = { kind: 'ok', text: 'Fixing auth bug. Next: run tests.' }
|
||||
const result = await callFn!('', fakeContext)
|
||||
expect(result.type).toBe('text')
|
||||
expect(result.value).toContain('Fixing auth bug')
|
||||
})
|
||||
|
||||
test('returns text value on api-error result', async () => {
|
||||
mockRecapResult = { kind: 'api-error', text: 'Rate limit hit.' }
|
||||
const result = await callFn!('', fakeContext)
|
||||
expect(result.type).toBe('text')
|
||||
expect(result.value).toContain('Rate limit hit')
|
||||
})
|
||||
|
||||
test('returns helpful message on no-turn result', async () => {
|
||||
mockRecapResult = { kind: 'no-turn' }
|
||||
const result = await callFn!('', fakeContext)
|
||||
expect(result.type).toBe('text')
|
||||
expect(result.value.length).toBeGreaterThan(5)
|
||||
expect(result.value).not.toBe('')
|
||||
})
|
||||
|
||||
test('returns cancelled message on aborted result', async () => {
|
||||
mockRecapResult = { kind: 'aborted' }
|
||||
const result = await callFn!('', fakeContext)
|
||||
expect(result.type).toBe('text')
|
||||
expect(result.value.toLowerCase()).toMatch(/cancel|abort/)
|
||||
})
|
||||
|
||||
test('returns error message on failed result', async () => {
|
||||
mockRecapResult = { kind: 'failed' }
|
||||
const result = await callFn!('', fakeContext)
|
||||
expect(result.type).toBe('text')
|
||||
expect(result.value.length).toBeGreaterThan(5)
|
||||
})
|
||||
|
||||
test('passes abortController signal to generateRecap', async () => {
|
||||
let capturedSignal: AbortSignal | undefined
|
||||
mock.module('src/commands/recap/generateRecap.js', () => ({
|
||||
generateRecap: async (signal: AbortSignal) => {
|
||||
capturedSignal = signal
|
||||
return { kind: 'ok', text: 'Done.' }
|
||||
},
|
||||
}))
|
||||
const fresh = await import('../index.js')
|
||||
const loaded = await fresh.default.load()
|
||||
await loaded.call('', fakeContext)
|
||||
expect(capturedSignal).toBe(fakeContext.abortController.signal)
|
||||
})
|
||||
})
|
||||
125
src/commands/recap/generateRecap.ts
Normal file
125
src/commands/recap/generateRecap.ts
Normal file
@@ -0,0 +1,125 @@
|
||||
/**
|
||||
* generateRecap — On-demand "while you were away" session recap.
|
||||
*
|
||||
* Implementation mirrors the official v2.1.123 tt8() function:
|
||||
* - Reads getLastCacheSafeParams() (set after each turn) to share prompt cache
|
||||
* - Forks a single-turn query with the recap prompt
|
||||
* - Returns a discriminated union: ok / api-error / no-turn / aborted / failed
|
||||
*
|
||||
* The fork uses skipTranscript + skipCacheWrite to stay ephemeral and avoid
|
||||
* polluting the main session log or creating unnecessary cache entries.
|
||||
*/
|
||||
|
||||
import { APIUserAbortError } from '@anthropic-ai/sdk'
|
||||
import { logForDebugging } from '../../utils/debug.js'
|
||||
import {
|
||||
getLastCacheSafeParams,
|
||||
runForkedAgent,
|
||||
} from '../../utils/forkedAgent.js'
|
||||
import {
|
||||
createUserMessage,
|
||||
getAssistantMessageText,
|
||||
} from '../../utils/messages.js'
|
||||
|
||||
// Matches the official G$9 constant in v2.1.123:
|
||||
// "lead with goal + current task, then one next action, ≤40 words, no markdown"
|
||||
const RECAP_PROMPT_EN =
|
||||
'The user stepped away and is coming back. Recap in under 40 words, 1-2 plain sentences, no markdown. Lead with the overall goal and current task, then the one next action. Skip root-cause narrative, fix internals, secondary to-dos, and em-dash tangents.'
|
||||
|
||||
const RECAP_PROMPT_ZH =
|
||||
'用户离开后回来了。用中文写 1-2 句话,不超过 60 字,无 markdown。先说明高层目标和当前任务,再说明下一步操作。跳过根因分析和次要待办。'
|
||||
|
||||
export type RecapResult =
|
||||
| { kind: 'ok'; text: string }
|
||||
| { kind: 'api-error'; text: string }
|
||||
| { kind: 'no-turn' }
|
||||
| { kind: 'aborted' }
|
||||
| { kind: 'failed' }
|
||||
|
||||
async function getRecapPrompt(): Promise<string> {
|
||||
try {
|
||||
const { getResolvedLanguage } = await import('../../utils/language.js')
|
||||
return getResolvedLanguage() === 'zh' ? RECAP_PROMPT_ZH : RECAP_PROMPT_EN
|
||||
} catch {
|
||||
return RECAP_PROMPT_EN
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates a single-sentence recap of the current session.
|
||||
* Uses the cached CacheSafeParams from the last turn so the request
|
||||
* can share the prompt-cache prefix with the main loop.
|
||||
*
|
||||
* @param signal - AbortSignal to cancel in-flight requests
|
||||
* @returns RecapResult discriminated union
|
||||
*/
|
||||
export async function generateRecap(signal: AbortSignal): Promise<RecapResult> {
|
||||
const cacheSafeParams = getLastCacheSafeParams()
|
||||
if (!cacheSafeParams) {
|
||||
logForDebugging('[recap] no CacheSafeParams saved, skipping')
|
||||
return { kind: 'no-turn' }
|
||||
}
|
||||
|
||||
// Wrap the parent signal so we can abort our inner request independently
|
||||
const inner = new AbortController()
|
||||
signal.addEventListener('abort', () => inner.abort(), { once: true })
|
||||
|
||||
try {
|
||||
const { messages } = await runForkedAgent({
|
||||
promptMessages: [createUserMessage({ content: await getRecapPrompt() })],
|
||||
cacheSafeParams,
|
||||
canUseTool: async () => ({
|
||||
behavior: 'deny' as const,
|
||||
message: 'Recap cannot use tools',
|
||||
decisionReason: { type: 'other' as const, reason: 'away_summary' },
|
||||
}),
|
||||
overrides: { abortController: inner },
|
||||
querySource: 'away_summary',
|
||||
forkLabel: 'away_summary',
|
||||
maxTurns: 1,
|
||||
skipCacheWrite: true,
|
||||
skipTranscript: true,
|
||||
})
|
||||
|
||||
if (signal.aborted) {
|
||||
return { kind: 'aborted' }
|
||||
}
|
||||
|
||||
// Check for API error response in the message list
|
||||
const errorMsg = messages.find(
|
||||
m => m.type === 'assistant' && m.isApiErrorMessage,
|
||||
)
|
||||
if (errorMsg) {
|
||||
return {
|
||||
kind: 'api-error',
|
||||
text: getAssistantMessageText(errorMsg) ?? '',
|
||||
}
|
||||
}
|
||||
|
||||
// Extract the assistant text from the last assistant message
|
||||
const assistantMsg = messages
|
||||
.filter(m => m.type === 'assistant' && !m.isApiErrorMessage)
|
||||
.pop()
|
||||
|
||||
if (!assistantMsg) {
|
||||
return { kind: 'failed' }
|
||||
}
|
||||
|
||||
const text = getAssistantMessageText(assistantMsg)
|
||||
if (!text || text.trim().length === 0) {
|
||||
return { kind: 'failed' }
|
||||
}
|
||||
|
||||
return { kind: 'ok', text: text.trim() }
|
||||
} catch (err) {
|
||||
if (
|
||||
err instanceof APIUserAbortError ||
|
||||
signal.aborted ||
|
||||
inner.signal.aborted
|
||||
) {
|
||||
return { kind: 'aborted' }
|
||||
}
|
||||
logForDebugging(`[recap] generation failed: ${err}`)
|
||||
return { kind: 'failed' }
|
||||
}
|
||||
}
|
||||
86
src/commands/recap/index.ts
Normal file
86
src/commands/recap/index.ts
Normal file
@@ -0,0 +1,86 @@
|
||||
/**
|
||||
* /recap — Generate a one-line session recap now.
|
||||
*
|
||||
* Aliases: /away, /catchup
|
||||
*
|
||||
* Mirrors the official v2.1.123 implementation:
|
||||
* - Gated by AWAY_SUMMARY feature flag (must be set at runtime) AND
|
||||
* the 'tengu_sedge_lantern' GrowthBook flag (default: true)
|
||||
* - Calls generateRecap() which shares the main loop's prompt-cache prefix
|
||||
* - Returns a short (≤40 word) plain-text sentence describing the current
|
||||
* goal, active task, and next action — no markdown, no status reports
|
||||
*
|
||||
* When the user has been away and comes back, they can type /recap (or /away /
|
||||
* /catchup) to get an instant orientation without scrolling back through history.
|
||||
*
|
||||
* isEnabled guard: the automatic "while you were away" card in REPL.tsx already
|
||||
* checks feature('AWAY_SUMMARY'). For the manual /recap command we check the
|
||||
* same GrowthBook flag so the two surfaces stay in sync.
|
||||
*/
|
||||
import { feature } from 'bun:bundle'
|
||||
import { getFeatureValue_CACHED_MAY_BE_STALE } from '../../services/analytics/growthbook.js'
|
||||
import type {
|
||||
Command,
|
||||
LocalCommandCall,
|
||||
LocalCommandResult,
|
||||
} from '../../types/command.js'
|
||||
|
||||
// ── Call implementation ───────────────────────────────────────────────────────
|
||||
|
||||
const call: LocalCommandCall = async (_args, context) => {
|
||||
// Dynamic import keeps the heavy forkedAgent dependency out of module load
|
||||
const { generateRecap } = await import('./generateRecap.js')
|
||||
|
||||
const signal = context.abortController?.signal ?? new AbortController().signal
|
||||
const result = await generateRecap(signal)
|
||||
|
||||
switch (result.kind) {
|
||||
case 'ok':
|
||||
case 'api-error':
|
||||
return { type: 'text', value: result.text } satisfies LocalCommandResult
|
||||
|
||||
case 'no-turn':
|
||||
return {
|
||||
type: 'text',
|
||||
value: 'Nothing to recap yet \u2014 send a message first.',
|
||||
} satisfies LocalCommandResult
|
||||
|
||||
case 'aborted':
|
||||
return {
|
||||
type: 'text',
|
||||
value: 'Recap cancelled.',
|
||||
} satisfies LocalCommandResult
|
||||
|
||||
case 'failed':
|
||||
return {
|
||||
type: 'text',
|
||||
value: 'Couldn\u2019t generate a recap. Run with --debug for details.',
|
||||
} satisfies LocalCommandResult
|
||||
}
|
||||
}
|
||||
|
||||
// ── Command declaration ───────────────────────────────────────────────────────
|
||||
|
||||
const recap = {
|
||||
type: 'local',
|
||||
name: 'recap',
|
||||
description: 'Generate a one-line session recap now',
|
||||
aliases: ['away', 'catchup'],
|
||||
/**
|
||||
* Enabled when:
|
||||
* 1. The AWAY_SUMMARY feature flag is on (build/env), AND
|
||||
* 2. The 'tengu_sedge_lantern' GrowthBook flag is true (default: true)
|
||||
*
|
||||
* This matches the isEnabled() predicate used in the official binary and
|
||||
* keeps this command in sync with the automatic away-summary card in REPL.
|
||||
*/
|
||||
isEnabled: (): boolean => {
|
||||
if (!feature('AWAY_SUMMARY')) return false
|
||||
return getFeatureValue_CACHED_MAY_BE_STALE('tengu_sedge_lantern', true)
|
||||
},
|
||||
supportsNonInteractive: false,
|
||||
isHidden: false,
|
||||
load: () => Promise.resolve({ call }),
|
||||
} satisfies Command
|
||||
|
||||
export default recap
|
||||
@@ -1,10 +1,8 @@
|
||||
import type { Command } from '../../commands.js'
|
||||
|
||||
const stats = {
|
||||
type: 'local-jsx',
|
||||
name: 'stats',
|
||||
description: 'Show your Claude Code usage statistics and activity',
|
||||
load: () => import('./stats.js'),
|
||||
} satisfies Command
|
||||
|
||||
export default stats
|
||||
/**
|
||||
* /stats — alias for /usage (v2.1.118 upstream alignment).
|
||||
*
|
||||
* /usage is the primary command; /cost and /stats are registered as aliases.
|
||||
* This file re-exports the unified usage command so that any code that imports
|
||||
* from stats/index directly still gets the correct Command object.
|
||||
*/
|
||||
export { default } from '../usage/index.js'
|
||||
|
||||
58
src/commands/teleport/__tests__/index.test.ts
Normal file
58
src/commands/teleport/__tests__/index.test.ts
Normal file
@@ -0,0 +1,58 @@
|
||||
/**
|
||||
* Tests for teleport/index.ts — command metadata + load() body.
|
||||
* We do NOT mock launchTeleport to avoid polluting launchTeleport.test.ts
|
||||
* via Bun's process-level mock.module cache.
|
||||
* load() is tested by verifying it resolves to an object with a call function.
|
||||
*/
|
||||
import { beforeAll, describe, expect, mock, test } from 'bun:test'
|
||||
|
||||
mock.module('bun:bundle', () => ({
|
||||
feature: (_name: string) => false,
|
||||
}))
|
||||
|
||||
let cmd: {
|
||||
load?: () => Promise<{ call: unknown }>
|
||||
isEnabled?: () => boolean
|
||||
name?: string
|
||||
type?: string
|
||||
aliases?: string[]
|
||||
getBridgeInvocationError?: (args: string) => string | undefined
|
||||
}
|
||||
|
||||
beforeAll(async () => {
|
||||
const mod = await import('../index.js')
|
||||
cmd = mod.default as typeof cmd
|
||||
})
|
||||
|
||||
describe('teleport index', () => {
|
||||
test('command name is teleport', () => {
|
||||
expect(cmd.name).toBe('teleport')
|
||||
})
|
||||
|
||||
test('command type is local-jsx', () => {
|
||||
expect(cmd.type).toBe('local-jsx')
|
||||
})
|
||||
|
||||
test('isEnabled returns true', () => {
|
||||
expect(cmd.isEnabled?.()).toBe(true)
|
||||
})
|
||||
|
||||
test('aliases includes tp', () => {
|
||||
expect(cmd.aliases).toContain('tp')
|
||||
})
|
||||
|
||||
test('getBridgeInvocationError returns error string (not bridge-safe)', () => {
|
||||
const err = cmd.getBridgeInvocationError?.('anything')
|
||||
expect(typeof err).toBe('string')
|
||||
expect(err).toContain('not bridge-safe')
|
||||
})
|
||||
|
||||
test('load() exists and is a function', () => {
|
||||
expect(typeof cmd.load).toBe('function')
|
||||
})
|
||||
|
||||
test('load() resolves to object with call function', async () => {
|
||||
const loaded = await cmd.load!()
|
||||
expect(typeof (loaded as { call?: unknown }).call).toBe('function')
|
||||
})
|
||||
})
|
||||
388
src/commands/teleport/__tests__/launchTeleport.test.ts
Normal file
388
src/commands/teleport/__tests__/launchTeleport.test.ts
Normal file
@@ -0,0 +1,388 @@
|
||||
import { beforeAll, beforeEach, describe, expect, mock, test } from 'bun:test'
|
||||
import type { LogOption } from '../../../types/logs.js'
|
||||
import type { LocalJSXCommandCall } from '../../../types/command.js'
|
||||
import { debugMock } from '../../../../tests/mocks/debug.js'
|
||||
import { logMock } from '../../../../tests/mocks/log.js'
|
||||
|
||||
// ── Mock module-level side effects BEFORE any imports ──
|
||||
mock.module('src/utils/log.ts', logMock)
|
||||
mock.module('src/utils/debug.ts', debugMock)
|
||||
mock.module('bun:bundle', () => ({
|
||||
feature: (_name: string) => false,
|
||||
}))
|
||||
|
||||
// ── Teleport utilities ──
|
||||
const validateGitStateMock = mock(() => Promise.resolve())
|
||||
const teleportResumeMock = mock(
|
||||
(_id: string, _onProgress?: (stage: string) => void) =>
|
||||
Promise.resolve({ log: [], branch: 'main' }),
|
||||
)
|
||||
|
||||
mock.module('src/utils/teleport.js', () => ({
|
||||
validateGitState: validateGitStateMock,
|
||||
teleportResumeCodeSession: teleportResumeMock,
|
||||
processMessagesForTeleportResume: mock(
|
||||
(_msgs: unknown[], _err: unknown) => [],
|
||||
),
|
||||
checkOutTeleportedSessionBranch: mock(() =>
|
||||
Promise.resolve({ branchName: 'main', branchError: null }),
|
||||
),
|
||||
validateSessionRepository: mock(() => Promise.resolve({ status: 'match' })),
|
||||
teleportToRemoteWithErrorHandling: mock(() => Promise.resolve(null)),
|
||||
teleportFromSessionsAPI: mock(() =>
|
||||
Promise.resolve({ log: [], branch: 'main' }),
|
||||
),
|
||||
pollRemoteSessionEvents: mock(() => Promise.resolve([])),
|
||||
teleportToRemote: mock(() => Promise.resolve(null)),
|
||||
archiveRemoteSession: mock(() => Promise.resolve()),
|
||||
}))
|
||||
|
||||
// ── Sessions API mock ──
|
||||
const fetchSessionsMock = mock(() =>
|
||||
Promise.resolve([
|
||||
{
|
||||
id: 'session_01ABC',
|
||||
title: 'Test session',
|
||||
status: 'idle',
|
||||
created_at: '2026-04-29',
|
||||
},
|
||||
]),
|
||||
)
|
||||
mock.module('src/utils/teleport/api.js', () => ({
|
||||
fetchCodeSessionsFromSessionsAPI: fetchSessionsMock,
|
||||
}))
|
||||
|
||||
// ── Session storage ──
|
||||
const mockLog: LogOption = {
|
||||
date: '2026-04-29',
|
||||
messages: [],
|
||||
value: 0,
|
||||
created: new Date(),
|
||||
modified: new Date(),
|
||||
firstPrompt: '',
|
||||
messageCount: 0,
|
||||
isSidechain: false,
|
||||
}
|
||||
const getLastSessionLogMock = mock(() => Promise.resolve(mockLog))
|
||||
mock.module('src/utils/sessionStorage.js', () => ({
|
||||
getLastSessionLog: getLastSessionLogMock,
|
||||
}))
|
||||
|
||||
// ── Analytics ──
|
||||
const logEventMock = mock(() => {})
|
||||
mock.module('src/services/analytics/index.js', () => ({
|
||||
logEvent: logEventMock,
|
||||
logEventAsync: mock(() => Promise.resolve()),
|
||||
_resetForTesting: mock(() => {}),
|
||||
attachAnalyticsSink: mock(() => {}),
|
||||
stripProtoFields: mock((v: unknown) => v),
|
||||
}))
|
||||
|
||||
// ── Import SUT after mocks ──
|
||||
let callTeleport: LocalJSXCommandCall
|
||||
|
||||
beforeAll(async () => {
|
||||
const sut = await import('../launchTeleport.js')
|
||||
callTeleport = sut.callTeleport
|
||||
})
|
||||
|
||||
// ── Test helpers ──
|
||||
const onDone = mock((_result?: string, _opts?: unknown) => {})
|
||||
const resumeMockFn = mock(() => Promise.resolve())
|
||||
|
||||
function makeContext(withResume = true) {
|
||||
return {
|
||||
abortController: new AbortController(),
|
||||
resume: withResume ? resumeMockFn : undefined,
|
||||
} as unknown as Parameters<typeof callTeleport>[1]
|
||||
}
|
||||
|
||||
function getLoggedEvents(): string[] {
|
||||
return (logEventMock.mock.calls as unknown as [string, unknown][]).map(
|
||||
c => c[0],
|
||||
)
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
validateGitStateMock.mockClear()
|
||||
teleportResumeMock.mockClear()
|
||||
getLastSessionLogMock.mockClear()
|
||||
fetchSessionsMock.mockClear()
|
||||
logEventMock.mockClear()
|
||||
onDone.mockClear()
|
||||
resumeMockFn.mockClear()
|
||||
// Restore default happy-path implementations
|
||||
validateGitStateMock.mockImplementation(() => Promise.resolve())
|
||||
teleportResumeMock.mockImplementation(
|
||||
(_id: string, _onProgress?: (stage: string) => void) =>
|
||||
Promise.resolve({ log: [], branch: 'main' }),
|
||||
)
|
||||
getLastSessionLogMock.mockImplementation(() => Promise.resolve(mockLog))
|
||||
fetchSessionsMock.mockImplementation(() =>
|
||||
Promise.resolve([
|
||||
{
|
||||
id: 'session_01ABC',
|
||||
title: 'Test session',
|
||||
status: 'idle',
|
||||
created_at: '2026-04-29',
|
||||
},
|
||||
]),
|
||||
)
|
||||
})
|
||||
|
||||
describe('callTeleport', () => {
|
||||
test('empty args: fetches sessions list and shows picker', async () => {
|
||||
await callTeleport(onDone, makeContext(), ' ')
|
||||
const firstArg = onDone.mock.calls[0]?.[0] as string | undefined
|
||||
expect(firstArg).toMatch(/Available sessions/)
|
||||
expect(validateGitStateMock).not.toHaveBeenCalled()
|
||||
expect(teleportResumeMock).not.toHaveBeenCalled()
|
||||
const events = getLoggedEvents()
|
||||
expect(events).toContain('tengu_teleport_started')
|
||||
expect(events).toContain('tengu_teleport_source_decision')
|
||||
})
|
||||
|
||||
test('empty args + sessions fetch fails with generic error → fetch_fail event', async () => {
|
||||
fetchSessionsMock.mockImplementationOnce(() =>
|
||||
Promise.reject(new Error('network timeout')),
|
||||
)
|
||||
await callTeleport(onDone, makeContext(), '')
|
||||
const firstArg = onDone.mock.calls[0]?.[0] as string | undefined
|
||||
expect(firstArg).toMatch(/failed to fetch sessions/)
|
||||
const events = getLoggedEvents()
|
||||
expect(events).toContain('tengu_teleport_events_fetch_fail')
|
||||
})
|
||||
|
||||
test('empty args + sessions fetch fails with 401/forbidden → fetch_forbidden event', async () => {
|
||||
fetchSessionsMock.mockImplementationOnce(() =>
|
||||
Promise.reject(new Error('403 Forbidden: access denied')),
|
||||
)
|
||||
await callTeleport(onDone, makeContext(), '')
|
||||
const firstArg = onDone.mock.calls[0]?.[0] as string | undefined
|
||||
expect(firstArg).toMatch(/permission denied/)
|
||||
const events = getLoggedEvents()
|
||||
expect(events).toContain('tengu_teleport_events_fetch_forbidden')
|
||||
})
|
||||
|
||||
test('empty args + sessions fetch fails with 404/not-found → fetch_not_found event', async () => {
|
||||
fetchSessionsMock.mockImplementationOnce(() =>
|
||||
Promise.reject(new Error('404 Not Found')),
|
||||
)
|
||||
await callTeleport(onDone, makeContext(), '')
|
||||
const firstArg = onDone.mock.calls[0]?.[0] as string | undefined
|
||||
expect(firstArg).toMatch(/404/)
|
||||
const events = getLoggedEvents()
|
||||
expect(events).toContain('tengu_teleport_events_fetch_not_found')
|
||||
})
|
||||
|
||||
test('empty args + sessions fetch fails with token/unauthorized → bad_token event', async () => {
|
||||
fetchSessionsMock.mockImplementationOnce(() =>
|
||||
Promise.reject(new Error('unauthorized: invalid token')),
|
||||
)
|
||||
await callTeleport(onDone, makeContext(), '')
|
||||
const firstArg = onDone.mock.calls[0]?.[0] as string | undefined
|
||||
expect(firstArg).toMatch(/authentication error/)
|
||||
const events = getLoggedEvents()
|
||||
expect(events).toContain('tengu_teleport_error_bad_token')
|
||||
})
|
||||
|
||||
test('empty args + empty sessions list → teleport_null event', async () => {
|
||||
fetchSessionsMock.mockImplementationOnce(() => Promise.resolve([]))
|
||||
await callTeleport(onDone, makeContext(), '')
|
||||
const firstArg = onDone.mock.calls[0]?.[0] as string | undefined
|
||||
expect(firstArg).toMatch(/No active sessions/)
|
||||
const events = getLoggedEvents()
|
||||
expect(events).toContain('tengu_teleport_null')
|
||||
})
|
||||
|
||||
test('empty args + exactly PICKER_PAGE_CAP sessions → page_cap event', async () => {
|
||||
// 20 sessions triggers the page cap log
|
||||
const sessions = Array.from({ length: 20 }, (_, i) => ({
|
||||
id: `session_${i}`,
|
||||
title: `Session ${i}`,
|
||||
status: 'idle',
|
||||
created_at: '2026-04-29',
|
||||
}))
|
||||
fetchSessionsMock.mockImplementationOnce(() => Promise.resolve(sessions))
|
||||
await callTeleport(onDone, makeContext(), '')
|
||||
const events = getLoggedEvents()
|
||||
expect(events).toContain('tengu_teleport_page_cap')
|
||||
})
|
||||
|
||||
test('--print flag with no session id → shows picker in print mode', async () => {
|
||||
await callTeleport(onDone, makeContext(), '--print')
|
||||
const firstArg = onDone.mock.calls[0]?.[0] as string | undefined
|
||||
expect(firstArg).toMatch(/Available sessions/)
|
||||
})
|
||||
|
||||
test('short non-UUID session id is rejected without calling teleport', async () => {
|
||||
await callTeleport(onDone, makeContext(), 'abc')
|
||||
const firstArg = onDone.mock.calls[0]?.[0] as string | undefined
|
||||
expect(firstArg).toMatch(/Invalid session id/)
|
||||
expect(validateGitStateMock).not.toHaveBeenCalled()
|
||||
expect(teleportResumeMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
test('valid session id + git unclean → reports error, skips resume', async () => {
|
||||
validateGitStateMock.mockImplementation(() =>
|
||||
Promise.reject(
|
||||
new Error(
|
||||
'Git working directory is not clean. Please commit or stash your changes.',
|
||||
),
|
||||
),
|
||||
)
|
||||
await callTeleport(
|
||||
onDone,
|
||||
makeContext(),
|
||||
'12345678-abcd-ef01-2345-6789abcdef01',
|
||||
)
|
||||
const firstArg = onDone.mock.calls[0]?.[0] as string | undefined
|
||||
expect(firstArg).toMatch(/Cannot teleport/)
|
||||
expect(firstArg).toMatch(/not clean/)
|
||||
expect(teleportResumeMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
test('valid session id + clean git → calls teleportResumeCodeSession + context.resume', async () => {
|
||||
const ctx = makeContext(true)
|
||||
await callTeleport(onDone, ctx, '12345678-abcd-ef01-2345-6789abcdef01')
|
||||
expect(teleportResumeMock).toHaveBeenCalledWith(
|
||||
'12345678-abcd-ef01-2345-6789abcdef01',
|
||||
expect.any(Function),
|
||||
)
|
||||
expect(resumeMockFn).toHaveBeenCalledWith(
|
||||
'12345678-abcd-ef01-2345-6789abcdef01',
|
||||
mockLog,
|
||||
'slash_command_session_id',
|
||||
)
|
||||
const events = getLoggedEvents()
|
||||
expect(events).toContain('tengu_teleport_resume_session')
|
||||
expect(events).toContain('tengu_teleport_first_message_success')
|
||||
})
|
||||
|
||||
test('progress callback is invoked during teleportResumeCodeSession (line 225)', async () => {
|
||||
teleportResumeMock.mockImplementationOnce(
|
||||
(_id: string, onProgress?: (stage: string) => void) => {
|
||||
onProgress?.('fetching_session')
|
||||
return Promise.resolve({ log: [], branch: 'main' })
|
||||
},
|
||||
)
|
||||
const ctx = makeContext(true)
|
||||
await callTeleport(onDone, ctx, '12345678-abcd-ef01-2345-6789abcdef01')
|
||||
expect(resumeMockFn).toHaveBeenCalled()
|
||||
const events = getLoggedEvents()
|
||||
expect(events).toContain('tengu_teleport_resume_session')
|
||||
})
|
||||
|
||||
test('teleportResumeCodeSession throws not-found error → fires session_not_found_ event', async () => {
|
||||
teleportResumeMock.mockImplementation(() =>
|
||||
Promise.reject(new Error('Session not found')),
|
||||
)
|
||||
await callTeleport(
|
||||
onDone,
|
||||
makeContext(),
|
||||
'12345678-abcd-ef01-2345-6789abcdef01',
|
||||
)
|
||||
const firstArg = onDone.mock.calls[0]?.[0] as string | undefined
|
||||
expect(firstArg).toMatch(/Teleport failed/)
|
||||
const events = getLoggedEvents()
|
||||
expect(events).toContain('tengu_teleport_error_session_not_found_')
|
||||
})
|
||||
|
||||
test('teleportResumeCodeSession throws repo mismatch → fires repo_mismatch event', async () => {
|
||||
teleportResumeMock.mockImplementation(() =>
|
||||
Promise.reject(new Error('repo mismatch: expected acme/foo')),
|
||||
)
|
||||
await callTeleport(
|
||||
onDone,
|
||||
makeContext(),
|
||||
'12345678-abcd-ef01-2345-6789abcdef01',
|
||||
)
|
||||
const events = getLoggedEvents()
|
||||
expect(events).toContain('tengu_teleport_error_repo_mismatch_sessions_api')
|
||||
})
|
||||
|
||||
test('git dir error → fires tengu_teleport_error_repo_not_in_git_dir_ event', async () => {
|
||||
teleportResumeMock.mockImplementationOnce(() =>
|
||||
Promise.reject(new Error('not in git directory: /tmp/test')),
|
||||
)
|
||||
await callTeleport(
|
||||
onDone,
|
||||
makeContext(),
|
||||
'12345678-abcd-ef01-2345-6789abcdef01',
|
||||
)
|
||||
const events = getLoggedEvents()
|
||||
expect(events).toContain(
|
||||
'tengu_teleport_error_repo_not_in_git_dir_sessions_api',
|
||||
)
|
||||
})
|
||||
|
||||
test('cancelled error → fires tengu_teleport_cancelled event', async () => {
|
||||
teleportResumeMock.mockImplementationOnce(() =>
|
||||
Promise.reject(new Error('operation was cancelled')),
|
||||
)
|
||||
await callTeleport(
|
||||
onDone,
|
||||
makeContext(),
|
||||
'12345678-abcd-ef01-2345-6789abcdef01',
|
||||
)
|
||||
const events = getLoggedEvents()
|
||||
expect(events).toContain('tengu_teleport_cancelled')
|
||||
})
|
||||
|
||||
test('token/unauthorized error → fires bad_token event', async () => {
|
||||
teleportResumeMock.mockImplementationOnce(() =>
|
||||
Promise.reject(new Error('401 unauthorized: bad token')),
|
||||
)
|
||||
await callTeleport(
|
||||
onDone,
|
||||
makeContext(),
|
||||
'12345678-abcd-ef01-2345-6789abcdef01',
|
||||
)
|
||||
const events = getLoggedEvents()
|
||||
expect(events).toContain('tengu_teleport_error_bad_token')
|
||||
})
|
||||
|
||||
test('status/4xx error → fires bad_status event', async () => {
|
||||
teleportResumeMock.mockImplementationOnce(() =>
|
||||
Promise.reject(new Error('500 internal server error bad status')),
|
||||
)
|
||||
await callTeleport(
|
||||
onDone,
|
||||
makeContext(),
|
||||
'12345678-abcd-ef01-2345-6789abcdef01',
|
||||
)
|
||||
const events = getLoggedEvents()
|
||||
expect(events).toContain('tengu_teleport_error_bad_status')
|
||||
})
|
||||
|
||||
test('valid session id without context.resume → fallback message', async () => {
|
||||
const ctx = makeContext(false) // no resume callback
|
||||
await callTeleport(onDone, ctx, '12345678-abcd-ef01-2345-6789abcdef01')
|
||||
const firstArg = onDone.mock.calls[0]?.[0] as string | undefined
|
||||
expect(firstArg).toMatch(/did not provide a resume callback/)
|
||||
})
|
||||
|
||||
test('valid session id without context.resume + print mode → success message', async () => {
|
||||
const ctx = makeContext(false)
|
||||
await callTeleport(
|
||||
onDone,
|
||||
ctx,
|
||||
'--print 12345678-abcd-ef01-2345-6789abcdef01',
|
||||
)
|
||||
const firstArg = onDone.mock.calls[0]?.[0] as string | undefined
|
||||
expect(typeof firstArg).toBe('string')
|
||||
})
|
||||
|
||||
test('log not found after resume → fallback message', async () => {
|
||||
getLastSessionLogMock.mockImplementation(() =>
|
||||
Promise.resolve(null as unknown as LogOption),
|
||||
)
|
||||
await callTeleport(
|
||||
onDone,
|
||||
makeContext(),
|
||||
'12345678-abcd-ef01-2345-6789abcdef01',
|
||||
)
|
||||
const firstArg = onDone.mock.calls[0]?.[0] as string | undefined
|
||||
expect(firstArg).toMatch(/local log was not found/)
|
||||
})
|
||||
})
|
||||
@@ -1 +0,0 @@
|
||||
export default { isEnabled: () => false, isHidden: true, name: 'stub' }
|
||||
23
src/commands/teleport/index.ts
Normal file
23
src/commands/teleport/index.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import type { Command } from '../../types/command.js'
|
||||
|
||||
const teleport: Command = {
|
||||
type: 'local-jsx',
|
||||
name: 'teleport',
|
||||
// Official v2.1.123 advertises alias `tp` (reverse-engineered from
|
||||
// claude.exe: `name:"teleport",aliases:["tp"]`). Keeping it for parity.
|
||||
aliases: ['tp'],
|
||||
description: 'Resume a Claude Code session from claude.ai',
|
||||
// REPL markdown renderer strips `<...>` as HTML tags — use uppercase.
|
||||
argumentHint: 'SESSION_ID',
|
||||
isHidden: false,
|
||||
isEnabled: () => true,
|
||||
bridgeSafe: false,
|
||||
getBridgeInvocationError: (_args: string) =>
|
||||
'teleport resumes the REPL and is not bridge-safe',
|
||||
load: async () => {
|
||||
const m = await import('./launchTeleport.js')
|
||||
return { call: m.callTeleport }
|
||||
},
|
||||
}
|
||||
|
||||
export default teleport
|
||||
314
src/commands/teleport/launchTeleport.ts
Normal file
314
src/commands/teleport/launchTeleport.ts
Normal file
@@ -0,0 +1,314 @@
|
||||
import type { UUID } from 'node:crypto'
|
||||
import {
|
||||
type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
||||
logEvent,
|
||||
} from '../../services/analytics/index.js'
|
||||
import type { LocalJSXCommandCall } from '../../types/command.js'
|
||||
import type { LogOption } from '../../types/logs.js'
|
||||
import { getLastSessionLog } from '../../utils/sessionStorage.js'
|
||||
import {
|
||||
teleportResumeCodeSession,
|
||||
validateGitState,
|
||||
} from '../../utils/teleport.js'
|
||||
import { fetchCodeSessionsFromSessionsAPI } from '../../utils/teleport/api.js'
|
||||
|
||||
// Minimum length for a UUID-like session ID (8 hex chars with dashes allowed)
|
||||
const SESSION_ID_MIN_LENGTH = 8
|
||||
|
||||
// Maximum sessions to display in the interactive picker
|
||||
const PICKER_PAGE_CAP = 20
|
||||
|
||||
function meta(
|
||||
s: string,
|
||||
): AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS {
|
||||
return s as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS
|
||||
}
|
||||
|
||||
export type TeleportProgressStep =
|
||||
| 'fetch'
|
||||
| 'validate'
|
||||
| 'resume'
|
||||
| 'ready'
|
||||
| 'error'
|
||||
|
||||
/**
|
||||
* Formats a sessions list as a text picker (no interactive UI in headless mode).
|
||||
* Returns a prompt the user can copy a session ID from.
|
||||
*/
|
||||
function formatSessionsPicker(
|
||||
sessions: Array<{
|
||||
id: string
|
||||
title: string
|
||||
status: string
|
||||
created_at: string
|
||||
}>,
|
||||
): string {
|
||||
const rows = sessions.slice(0, PICKER_PAGE_CAP).map((s, i) => {
|
||||
const idx = String(i + 1).padStart(2)
|
||||
const title = s.title.slice(0, 50).padEnd(50)
|
||||
const status = s.status.padEnd(14)
|
||||
const created = s.created_at.slice(0, 10)
|
||||
return ` ${idx}. ${title} ${status} ${created} id=${s.id}`
|
||||
})
|
||||
return [
|
||||
'## Available sessions (most recent first)',
|
||||
'',
|
||||
...rows,
|
||||
'',
|
||||
'Run `/teleport <session-id>` to resume a session.',
|
||||
].join('\n')
|
||||
}
|
||||
|
||||
/**
|
||||
* /teleport [session-id]
|
||||
*
|
||||
* Without session-id: fetches the user's session list from the Sessions API
|
||||
* and renders an interactive picker (or text list in headless mode).
|
||||
*
|
||||
* With session-id:
|
||||
* 1. Validates local git state (must be clean)
|
||||
* 2. Fetches session logs + branch via teleportResumeCodeSession()
|
||||
* 3. Looks up the session LogOption by ID
|
||||
* 4. Hands off to the REPL via context.resume()
|
||||
*
|
||||
* Telemetry coverage:
|
||||
* - tengu_teleport_started
|
||||
* - tengu_teleport_events_fetch_fail
|
||||
* - tengu_teleport_page_cap
|
||||
* - tengu_teleport_source_decision
|
||||
* - tengu_teleport_resume_session
|
||||
* - tengu_teleport_first_message_success
|
||||
* - tengu_teleport_first_message_error
|
||||
* - tengu_teleport_failed
|
||||
* - tengu_teleport_cancelled
|
||||
* - tengu_teleport_null
|
||||
* - tengu_teleport_errors_detected
|
||||
* - tengu_teleport_errors_resolved
|
||||
* - tengu_teleport_error_session_not_found_
|
||||
* - tengu_teleport_error_repo_mismatch_sessions_api
|
||||
* - tengu_teleport_error_repo_not_in_git_dir_sessions_api
|
||||
* - tengu_teleport_error_bad_token
|
||||
* - tengu_teleport_error_bad_status
|
||||
*/
|
||||
export const callTeleport: LocalJSXCommandCall = async (
|
||||
onDone,
|
||||
context,
|
||||
args,
|
||||
) => {
|
||||
const rawArgs = args.trim()
|
||||
// --print flag: headless / non-interactive output
|
||||
const isPrintMode = rawArgs === '--print' || rawArgs.startsWith('--print ')
|
||||
const sessionId = isPrintMode
|
||||
? rawArgs.replace(/^--print\s*/, '').trim()
|
||||
: rawArgs
|
||||
|
||||
logEvent('tengu_teleport_started', {
|
||||
has_session_id: meta(sessionId ? 'true' : 'false'),
|
||||
})
|
||||
|
||||
// ── No session ID: interactive picker ──
|
||||
if (!sessionId) {
|
||||
logEvent('tengu_teleport_source_decision', {
|
||||
source: meta('sessions_api'),
|
||||
})
|
||||
|
||||
let sessions: Array<{
|
||||
id: string
|
||||
title: string
|
||||
status: string
|
||||
created_at: string
|
||||
}>
|
||||
try {
|
||||
const raw = await fetchCodeSessionsFromSessionsAPI()
|
||||
sessions = raw.map(s => ({
|
||||
id: s.id,
|
||||
title: s.title ?? 'Untitled',
|
||||
status: (s.status ?? 'unknown') as string,
|
||||
created_at: s.created_at ?? '',
|
||||
}))
|
||||
} catch (fetchErr: unknown) {
|
||||
const msg =
|
||||
fetchErr instanceof Error ? fetchErr.message : String(fetchErr)
|
||||
|
||||
if (/forbidden|401|403/i.test(msg)) {
|
||||
logEvent('tengu_teleport_events_fetch_forbidden', {
|
||||
error: meta(msg.slice(0, 200)),
|
||||
})
|
||||
onDone(
|
||||
'Teleport: permission denied fetching sessions. Check your OAuth token (`claude auth status`).',
|
||||
{ display: 'system' },
|
||||
)
|
||||
return null
|
||||
}
|
||||
if (/not found|404/i.test(msg)) {
|
||||
logEvent('tengu_teleport_events_fetch_not_found', {
|
||||
error: meta(msg.slice(0, 200)),
|
||||
})
|
||||
onDone(
|
||||
'Teleport: sessions endpoint returned 404. The Sessions API may not be available for your account.',
|
||||
{ display: 'system' },
|
||||
)
|
||||
return null
|
||||
}
|
||||
if (/token|unauthorized/i.test(msg)) {
|
||||
logEvent('tengu_teleport_error_bad_token', {
|
||||
error: meta(msg.slice(0, 200)),
|
||||
})
|
||||
onDone(
|
||||
`Teleport: authentication error — ${msg}. Try \`claude auth login\`.`,
|
||||
{ display: 'system' },
|
||||
)
|
||||
return null
|
||||
}
|
||||
|
||||
logEvent('tengu_teleport_events_fetch_fail', {
|
||||
error: meta(msg.slice(0, 200)),
|
||||
})
|
||||
onDone(
|
||||
`Teleport: failed to fetch sessions — ${msg}.\nUsage: /teleport SESSION_ID`,
|
||||
{ display: 'system' },
|
||||
)
|
||||
return null
|
||||
}
|
||||
|
||||
if (sessions.length === 0) {
|
||||
logEvent('tengu_teleport_null', {})
|
||||
onDone(
|
||||
'No active sessions found on claude.ai/code.\nStart a new session at https://claude.ai/code',
|
||||
{ display: 'system' },
|
||||
)
|
||||
return null
|
||||
}
|
||||
|
||||
if (sessions.length >= PICKER_PAGE_CAP) {
|
||||
logEvent('tengu_teleport_page_cap', {
|
||||
count: meta(String(sessions.length)),
|
||||
})
|
||||
}
|
||||
|
||||
const pickerText = formatSessionsPicker(sessions)
|
||||
|
||||
if (isPrintMode) {
|
||||
onDone(pickerText, { display: 'system' })
|
||||
return null
|
||||
}
|
||||
|
||||
// Interactive context: display the list and prompt user to run with an ID.
|
||||
// A full Ink <SelectInput> picker requires an event loop that isn't safely
|
||||
// available from all command contexts; text list is the portable fallback.
|
||||
onDone(pickerText, { display: 'system' })
|
||||
return null
|
||||
}
|
||||
|
||||
// ── Basic format guard ──
|
||||
if (
|
||||
sessionId.length < SESSION_ID_MIN_LENGTH ||
|
||||
!/^[0-9a-f-]{8,}$/i.test(sessionId)
|
||||
) {
|
||||
logEvent('tengu_teleport_error_bad_status', {
|
||||
error: meta(`invalid_session_id: ${sessionId.slice(0, 40)}`),
|
||||
})
|
||||
onDone(
|
||||
`Invalid session id "${sessionId}". Expected a UUID-like string (e.g. 12345678-abcd-...).`,
|
||||
{ display: 'system' },
|
||||
)
|
||||
return null
|
||||
}
|
||||
|
||||
logEvent('tengu_teleport_source_decision', { source: meta('explicit_id') })
|
||||
|
||||
// ── Progress tracker (internal, no Ink rendering needed) ──
|
||||
const steps: TeleportProgressStep[] = []
|
||||
const recordStep = (step: TeleportProgressStep) => {
|
||||
steps.push(step)
|
||||
}
|
||||
|
||||
// ── Git state validation ──
|
||||
recordStep('validate')
|
||||
try {
|
||||
await validateGitState()
|
||||
} catch (gErr: unknown) {
|
||||
const msg = gErr instanceof Error ? gErr.message : String(gErr)
|
||||
logEvent('tengu_teleport_errors_detected', {
|
||||
error: meta(msg.slice(0, 200)),
|
||||
})
|
||||
onDone(`Cannot teleport: ${msg}`, { display: 'system' })
|
||||
return null
|
||||
}
|
||||
|
||||
// ── Resume session ──
|
||||
recordStep('resume')
|
||||
try {
|
||||
let lastProgress = ''
|
||||
|
||||
await teleportResumeCodeSession(sessionId, stage => {
|
||||
lastProgress = String(stage)
|
||||
})
|
||||
|
||||
logEvent('tengu_teleport_resume_session', {
|
||||
stage: meta(lastProgress),
|
||||
})
|
||||
|
||||
recordStep('ready')
|
||||
|
||||
if (!context.resume) {
|
||||
logEvent('tengu_teleport_null', {})
|
||||
// resume callback unavailable (e.g. non-interactive context)
|
||||
if (isPrintMode) {
|
||||
onDone(`Session ${sessionId} fetched successfully.`, {
|
||||
display: 'system',
|
||||
})
|
||||
return null
|
||||
}
|
||||
onDone(
|
||||
`Teleport resume succeeded for ${sessionId}, but the REPL did not provide a resume callback.`,
|
||||
{ display: 'system' },
|
||||
)
|
||||
return null
|
||||
}
|
||||
|
||||
// Look up the session log so we can pass it to context.resume().
|
||||
recordStep('fetch')
|
||||
const log: LogOption | null = await getLastSessionLog(sessionId as UUID)
|
||||
if (!log) {
|
||||
logEvent('tengu_teleport_errors_detected', {
|
||||
error: meta('log_not_found_after_resume'),
|
||||
})
|
||||
onDone(
|
||||
`Teleport fetched session ${sessionId} but the local log was not found. Try /resume ${sessionId} manually.`,
|
||||
{ display: 'system' },
|
||||
)
|
||||
return null
|
||||
}
|
||||
|
||||
logEvent('tengu_teleport_errors_resolved', {})
|
||||
await context.resume(sessionId as UUID, log, 'slash_command_session_id')
|
||||
logEvent('tengu_teleport_first_message_success', {})
|
||||
return null
|
||||
} catch (err: unknown) {
|
||||
const msg = err instanceof Error ? err.message : String(err)
|
||||
|
||||
// Map error message content to specific telemetry event names
|
||||
let evt = 'tengu_teleport_failed'
|
||||
if (/not found/i.test(msg)) {
|
||||
evt = 'tengu_teleport_error_session_not_found_'
|
||||
} else if (/repo.*mismatch/i.test(msg)) {
|
||||
evt = 'tengu_teleport_error_repo_mismatch_sessions_api'
|
||||
} else if (/not in.*git|git.*dir/i.test(msg)) {
|
||||
evt = 'tengu_teleport_error_repo_not_in_git_dir_sessions_api'
|
||||
} else if (/cancelled|aborted/i.test(msg)) {
|
||||
evt = 'tengu_teleport_cancelled'
|
||||
} else if (/token|unauthorized|401/i.test(msg)) {
|
||||
evt = 'tengu_teleport_error_bad_token'
|
||||
} else if (/status|4\d\d|5\d\d/i.test(msg)) {
|
||||
evt = 'tengu_teleport_error_bad_status'
|
||||
}
|
||||
|
||||
logEvent(evt, { error: meta(msg.slice(0, 200)) })
|
||||
logEvent('tengu_teleport_first_message_error', {
|
||||
error: meta(msg.slice(0, 200)),
|
||||
})
|
||||
onDone(`Teleport failed: ${msg}`, { display: 'system' })
|
||||
return null
|
||||
}
|
||||
}
|
||||
246
src/commands/tui/__tests__/tui.test.ts
Normal file
246
src/commands/tui/__tests__/tui.test.ts
Normal file
@@ -0,0 +1,246 @@
|
||||
import { afterEach, beforeEach, describe, expect, mock, test } from 'bun:test'
|
||||
import {
|
||||
existsSync,
|
||||
mkdirSync,
|
||||
mkdtempSync,
|
||||
readFileSync,
|
||||
rmSync,
|
||||
} from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { getClaudeConfigHomeDir } from '../../../utils/envUtils.js'
|
||||
|
||||
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
|
||||
const origEnv: Record<string, string | undefined> = {}
|
||||
|
||||
beforeEach(() => {
|
||||
tmpDir = mkdtempSync(join(tmpdir(), 'tui-test-'))
|
||||
claudeDir = join(tmpDir, '.claude')
|
||||
mkdirSync(claudeDir, { recursive: true })
|
||||
process.env.CLAUDE_CONFIG_DIR = claudeDir
|
||||
// getClaudeConfigHomeDir is `memoize(...)` — clear its cache so this
|
||||
// suite's CLAUDE_CONFIG_DIR overrides any value cached by an earlier
|
||||
// test file in the same process.
|
||||
getClaudeConfigHomeDir.cache?.clear?.()
|
||||
// Save env vars we may mutate
|
||||
origEnv.CLAUDE_CODE_NO_FLICKER = process.env.CLAUDE_CODE_NO_FLICKER
|
||||
delete process.env.CLAUDE_CODE_NO_FLICKER
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
rmSync(tmpDir, { recursive: true, force: true })
|
||||
delete process.env.CLAUDE_CONFIG_DIR
|
||||
// Restore env vars
|
||||
if (origEnv.CLAUDE_CODE_NO_FLICKER === undefined) {
|
||||
delete process.env.CLAUDE_CODE_NO_FLICKER
|
||||
} else {
|
||||
process.env.CLAUDE_CODE_NO_FLICKER = origEnv.CLAUDE_CODE_NO_FLICKER
|
||||
}
|
||||
})
|
||||
|
||||
// Helper: invoke the command's call function
|
||||
async function invokeCmd(
|
||||
args: string,
|
||||
): Promise<{ type: string; value: string }> {
|
||||
const { callTui } = await import('../index.js')
|
||||
return callTui(args) as Promise<{ type: string; value: string }>
|
||||
}
|
||||
|
||||
describe('tui command metadata', () => {
|
||||
test('has correct name, type, and description', async () => {
|
||||
const mod = await import('../index.js')
|
||||
const cmd = mod.default
|
||||
expect(cmd.name).toBe('tui')
|
||||
expect(cmd.type).toBe('local-jsx')
|
||||
expect(cmd.description).toContain('flicker')
|
||||
})
|
||||
|
||||
test('interactive and noninteractive entries are mutually gated', async () => {
|
||||
const mod = await import('../index.js')
|
||||
const interactiveEnabled = mod.default.isEnabled?.()
|
||||
const nonInteractiveEnabled = mod.tuiNonInteractive.isEnabled?.()
|
||||
|
||||
expect(typeof interactiveEnabled).toBe('boolean')
|
||||
expect(nonInteractiveEnabled).toBe(!interactiveEnabled)
|
||||
})
|
||||
|
||||
test('supportsNonInteractive is true', async () => {
|
||||
const mod = await import('../index.js')
|
||||
const cmd = mod.tuiNonInteractive as unknown as {
|
||||
supportsNonInteractive: boolean
|
||||
type: string
|
||||
}
|
||||
expect(cmd.type).toBe('local')
|
||||
expect(cmd.supportsNonInteractive).toBe(true)
|
||||
})
|
||||
|
||||
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('TUI Mode Status')
|
||||
})
|
||||
})
|
||||
|
||||
describe('tui status subcommand', () => {
|
||||
test('reports disabled when no marker file', async () => {
|
||||
const result = await invokeCmd('status')
|
||||
expect(result.type).toBe('text')
|
||||
expect(result.value).toContain('disabled')
|
||||
})
|
||||
|
||||
test('reports enabled when marker file exists', async () => {
|
||||
const { getTuiMarkerPath } = await import('../index.js')
|
||||
const markerPath = getTuiMarkerPath()
|
||||
// Write the marker
|
||||
const { writeFileSync } = await import('node:fs')
|
||||
writeFileSync(markerPath, '1', 'utf8')
|
||||
|
||||
const result = await invokeCmd('status')
|
||||
expect(result.type).toBe('text')
|
||||
expect(result.value).toContain('enabled')
|
||||
})
|
||||
})
|
||||
|
||||
describe('tui on subcommand', () => {
|
||||
test('writes marker file', async () => {
|
||||
const { getTuiMarkerPath } = await import('../index.js')
|
||||
const markerPath = getTuiMarkerPath()
|
||||
expect(existsSync(markerPath)).toBe(false)
|
||||
|
||||
const result = await invokeCmd('on')
|
||||
expect(result.type).toBe('text')
|
||||
expect(result.value).toContain('enabled')
|
||||
expect(existsSync(markerPath)).toBe(true)
|
||||
})
|
||||
|
||||
test('idempotent: on when already on reports already enabled', async () => {
|
||||
await invokeCmd('on')
|
||||
const result = await invokeCmd('on')
|
||||
expect(result.type).toBe('text')
|
||||
// Second call still returns a success message
|
||||
expect(result.value).toContain('enabled')
|
||||
})
|
||||
})
|
||||
|
||||
describe('tui off subcommand', () => {
|
||||
test('removes marker file', async () => {
|
||||
const { getTuiMarkerPath } = await import('../index.js')
|
||||
await invokeCmd('on')
|
||||
expect(existsSync(getTuiMarkerPath())).toBe(true)
|
||||
|
||||
const result = await invokeCmd('off')
|
||||
expect(result.type).toBe('text')
|
||||
expect(result.value).toContain('disabled')
|
||||
expect(existsSync(getTuiMarkerPath())).toBe(false)
|
||||
})
|
||||
|
||||
test('off when already off returns graceful message', async () => {
|
||||
const result = await invokeCmd('off')
|
||||
expect(result.type).toBe('text')
|
||||
expect(result.value).toContain('not active')
|
||||
})
|
||||
})
|
||||
|
||||
describe('tui toggle subcommand', () => {
|
||||
test('toggle with no marker enables tui', async () => {
|
||||
const { getTuiMarkerPath } = await import('../index.js')
|
||||
const result = await invokeCmd('')
|
||||
expect(result.type).toBe('text')
|
||||
expect(result.value).toContain('enabled')
|
||||
expect(existsSync(getTuiMarkerPath())).toBe(true)
|
||||
})
|
||||
|
||||
test('toggle with marker disables tui', async () => {
|
||||
const { getTuiMarkerPath } = await import('../index.js')
|
||||
await invokeCmd('')
|
||||
expect(existsSync(getTuiMarkerPath())).toBe(true)
|
||||
|
||||
const result = await invokeCmd('')
|
||||
expect(result.type).toBe('text')
|
||||
expect(result.value).toContain('disabled')
|
||||
expect(existsSync(getTuiMarkerPath())).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('tui unknown subcommand', () => {
|
||||
test('returns usage text for unknown subcommand', async () => {
|
||||
const result = await invokeCmd('foobar')
|
||||
expect(result.type).toBe('text')
|
||||
expect(result.value).toContain('Usage')
|
||||
})
|
||||
})
|
||||
|
||||
describe('getTuiMarkerPath', () => {
|
||||
test('returns path under CLAUDE_CONFIG_DIR', async () => {
|
||||
const { getTuiMarkerPath } = await import('../index.js')
|
||||
const p = getTuiMarkerPath()
|
||||
expect(p).toContain(claudeDir)
|
||||
expect(p).toContain('.tui-mode')
|
||||
})
|
||||
})
|
||||
|
||||
describe('tui status env var display', () => {
|
||||
test('shows forced-on when CLAUDE_CODE_NO_FLICKER=1', async () => {
|
||||
process.env.CLAUDE_CODE_NO_FLICKER = '1'
|
||||
const result = await invokeCmd('status')
|
||||
expect(result.value).toContain('forced on via env var')
|
||||
delete process.env.CLAUDE_CODE_NO_FLICKER
|
||||
})
|
||||
|
||||
test('shows forced-off when CLAUDE_CODE_NO_FLICKER=0', async () => {
|
||||
process.env.CLAUDE_CODE_NO_FLICKER = '0'
|
||||
const result = await invokeCmd('status')
|
||||
expect(result.value).toContain('forced off via env var')
|
||||
delete process.env.CLAUDE_CODE_NO_FLICKER
|
||||
})
|
||||
})
|
||||
|
||||
describe('isTuiModeEnabled', () => {
|
||||
test('returns false when marker absent', async () => {
|
||||
const { isTuiModeEnabled } = await import('../index.js')
|
||||
expect(isTuiModeEnabled()).toBe(false)
|
||||
})
|
||||
|
||||
test('returns true when marker present', async () => {
|
||||
const { isTuiModeEnabled, getTuiMarkerPath } = await import('../index.js')
|
||||
const { writeFileSync } = await import('node:fs')
|
||||
writeFileSync(getTuiMarkerPath(), '1', 'utf8')
|
||||
expect(isTuiModeEnabled()).toBe(true)
|
||||
})
|
||||
})
|
||||
184
src/commands/tui/index.ts
Normal file
184
src/commands/tui/index.ts
Normal file
@@ -0,0 +1,184 @@
|
||||
import { existsSync, mkdirSync, 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 TUI-mode marker file.
|
||||
*
|
||||
* When this file exists, the user has opted in to flicker-free TUI mode
|
||||
* (alternate screen buffer via CLAUDE_CODE_NO_FLICKER=1). The marker is
|
||||
* session-independent: it persists across restarts so the user only needs to
|
||||
* run `/tui on` once.
|
||||
*
|
||||
* Shell-profile integration: add the following to ~/.bashrc / ~/.zshrc to
|
||||
* auto-enable TUI mode when the marker is present:
|
||||
*
|
||||
* [ -f "$HOME/.claude/.tui-mode" ] && export CLAUDE_CODE_NO_FLICKER=1
|
||||
*
|
||||
* Note: setting CLAUDE_CODE_NO_FLICKER at runtime cannot retroactively enter
|
||||
* the alternate screen buffer — the Ink render tree is already mounted. The
|
||||
* change takes effect on the NEXT session start.
|
||||
*/
|
||||
export function getTuiMarkerPath(): string {
|
||||
return join(getClaudeConfigHomeDir(), '.tui-mode')
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true when the TUI-mode marker file is present, meaning the user has
|
||||
* opted in to flicker-free alternate-screen rendering.
|
||||
*/
|
||||
export function isTuiModeEnabled(): boolean {
|
||||
return existsSync(getTuiMarkerPath())
|
||||
}
|
||||
|
||||
const USAGE_TEXT = [
|
||||
'Usage: /tui [subcommand]',
|
||||
'',
|
||||
' (no args) Toggle flicker-free TUI mode (alternate screen buffer)',
|
||||
' on Enable TUI mode',
|
||||
' off Disable TUI mode',
|
||||
' status Show current TUI mode state',
|
||||
'',
|
||||
'TUI mode uses the ANSI alternate screen buffer (\\x1b[?1049h) so the',
|
||||
'Claude Code UI occupies a clean full-screen area with no scroll-back',
|
||||
'flicker. The setting is stored in ~/.claude/.tui-mode and takes effect',
|
||||
'on the next session start.',
|
||||
'',
|
||||
'Shell-profile integration (auto-enable on every start):',
|
||||
' [ -f "$HOME/.claude/.tui-mode" ] && export CLAUDE_CODE_NO_FLICKER=1',
|
||||
'',
|
||||
'Environment override:',
|
||||
' CLAUDE_CODE_NO_FLICKER=1 force on (overrides marker)',
|
||||
' CLAUDE_CODE_NO_FLICKER=0 force off (overrides marker)',
|
||||
].join('\n')
|
||||
|
||||
function enableTui(): LocalCommandResult {
|
||||
const markerPath = getTuiMarkerPath()
|
||||
mkdirSync(getClaudeConfigHomeDir(), { recursive: true })
|
||||
writeFileSync(markerPath, new Date().toISOString(), 'utf8')
|
||||
return {
|
||||
type: 'text',
|
||||
value: [
|
||||
'## TUI mode enabled',
|
||||
'',
|
||||
`Marker written: \`${markerPath}\``,
|
||||
'',
|
||||
'Flicker-free alternate-screen rendering will be active on the next',
|
||||
'session start. Add this to your shell profile to make it permanent:',
|
||||
'',
|
||||
' [ -f "$HOME/.claude/.tui-mode" ] && export CLAUDE_CODE_NO_FLICKER=1',
|
||||
'',
|
||||
'To disable: `/tui off`',
|
||||
].join('\n'),
|
||||
}
|
||||
}
|
||||
|
||||
function disableTui(): LocalCommandResult {
|
||||
const markerPath = getTuiMarkerPath()
|
||||
if (!existsSync(markerPath)) {
|
||||
return {
|
||||
type: 'text',
|
||||
value: 'TUI mode was not active.',
|
||||
}
|
||||
}
|
||||
unlinkSync(markerPath)
|
||||
return {
|
||||
type: 'text',
|
||||
value: [
|
||||
'## TUI mode disabled',
|
||||
'',
|
||||
`Marker removed: \`${markerPath}\``,
|
||||
'',
|
||||
'Standard (non-alternate-screen) rendering will be used on the next',
|
||||
'session start.',
|
||||
'',
|
||||
'To re-enable: `/tui on`',
|
||||
].join('\n'),
|
||||
}
|
||||
}
|
||||
|
||||
export async function callTui(args: string): Promise<LocalCommandResult> {
|
||||
const sub = args.trim().toLowerCase()
|
||||
|
||||
// ── status ──────────────────────────────────────────────────────────
|
||||
if (sub === 'status') {
|
||||
const enabled = isTuiModeEnabled()
|
||||
const markerPath = getTuiMarkerPath()
|
||||
const envVal = process.env.CLAUDE_CODE_NO_FLICKER
|
||||
let envLine: string
|
||||
if (envVal === '1' || envVal === 'true') {
|
||||
envLine = 'CLAUDE_CODE_NO_FLICKER=1 (forced on via env var)'
|
||||
} else if (envVal === '0' || envVal === 'false') {
|
||||
envLine = 'CLAUDE_CODE_NO_FLICKER=0 (forced off via env var)'
|
||||
} else {
|
||||
envLine = 'CLAUDE_CODE_NO_FLICKER not set'
|
||||
}
|
||||
return {
|
||||
type: 'text',
|
||||
value: [
|
||||
'## TUI Mode Status',
|
||||
'',
|
||||
` Marker file: ${enabled ? 'present' : 'absent'} (\`${markerPath}\`)`,
|
||||
` Mode: ${enabled ? 'enabled' : 'disabled'}`,
|
||||
` Env var: ${envLine}`,
|
||||
'',
|
||||
'Note: changes take effect on the next session start.',
|
||||
].join('\n'),
|
||||
}
|
||||
}
|
||||
|
||||
// ── on ───────────────────────────────────────────────────────────────
|
||||
if (sub === 'on') {
|
||||
return enableTui()
|
||||
}
|
||||
|
||||
// ── off ──────────────────────────────────────────────────────────────
|
||||
if (sub === 'off') {
|
||||
return disableTui()
|
||||
}
|
||||
|
||||
// ── toggle (legacy default) ──────────────────────────────────────────
|
||||
if (sub === '' || sub === 'toggle') {
|
||||
return isTuiModeEnabled() ? disableTui() : enableTui()
|
||||
}
|
||||
|
||||
// ── unknown subcommand ───────────────────────────────────────────────
|
||||
return {
|
||||
type: 'text',
|
||||
value: [`Unknown subcommand: "${sub}"`, '', USAGE_TEXT].join('\n'),
|
||||
}
|
||||
}
|
||||
|
||||
const tuiCommand: Command = {
|
||||
type: 'local-jsx',
|
||||
name: 'tui',
|
||||
description:
|
||||
'Manage flicker-free TUI mode. Open actions or run: status, on, off, toggle',
|
||||
isHidden: false,
|
||||
isEnabled: () => !getIsNonInteractiveSession(),
|
||||
argumentHint: '[status|on|off|toggle]',
|
||||
bridgeSafe: true,
|
||||
getBridgeInvocationError: args =>
|
||||
args.trim()
|
||||
? undefined
|
||||
: 'Use /tui status/on/off/toggle over Remote Control.',
|
||||
load: () => import('./panel.js'),
|
||||
}
|
||||
|
||||
export const tuiNonInteractive: Command = {
|
||||
type: 'local',
|
||||
name: 'tui',
|
||||
description:
|
||||
'Toggle flicker-free TUI mode (alternate screen buffer). Subcommands: on, off, status',
|
||||
isHidden: false,
|
||||
isEnabled: () => getIsNonInteractiveSession(),
|
||||
supportsNonInteractive: true,
|
||||
bridgeSafe: true,
|
||||
load: async () => ({
|
||||
call: callTui,
|
||||
}),
|
||||
}
|
||||
|
||||
export default tuiCommand
|
||||
100
src/commands/tui/panel.tsx
Normal file
100
src/commands/tui/panel.tsx
Normal file
@@ -0,0 +1,100 @@
|
||||
import React, { useMemo, useState } from 'react';
|
||||
import { Box, Dialog, Text, useInput } from '@anthropic/ink';
|
||||
import type { LocalJSXCommandOnDone } from '../../types/command.js';
|
||||
import { callTui } from './index.js';
|
||||
|
||||
type TuiAction = {
|
||||
label: string;
|
||||
description: string;
|
||||
run: () => void;
|
||||
};
|
||||
|
||||
const ACTION_LABEL_COLUMN_WIDTH = 24;
|
||||
|
||||
async function runTuiAction(subcommand: string, onDone: LocalJSXCommandOnDone): Promise<void> {
|
||||
const result = await callTui(subcommand);
|
||||
if (result.type === 'text') {
|
||||
onDone(result.value, { display: 'system' });
|
||||
}
|
||||
}
|
||||
|
||||
function TuiPanel({ onDone }: { onDone: LocalJSXCommandOnDone }): React.ReactNode {
|
||||
const [selectedIndex, setSelectedIndex] = useState(0);
|
||||
|
||||
const actions = useMemo<TuiAction[]>(
|
||||
() => [
|
||||
{
|
||||
label: 'Status',
|
||||
description: 'Show marker and environment override state',
|
||||
run: () => void runTuiAction('status', onDone),
|
||||
},
|
||||
{
|
||||
label: 'Toggle',
|
||||
description: 'Flip persisted TUI mode for the next session',
|
||||
run: () => void runTuiAction('toggle', onDone),
|
||||
},
|
||||
{
|
||||
label: 'On',
|
||||
description: 'Enable flicker-free alternate-screen mode',
|
||||
run: () => void runTuiAction('on', onDone),
|
||||
},
|
||||
{
|
||||
label: 'Off',
|
||||
description: 'Disable flicker-free alternate-screen mode',
|
||||
run: () => void runTuiAction('off', 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="TUI Mode"
|
||||
subtitle={`${actions.length} actions`}
|
||||
onCancel={() => onDone('TUI mode 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 runTuiAction(trimmed, onDone);
|
||||
return null;
|
||||
}
|
||||
return <TuiPanel onDone={onDone} />;
|
||||
}
|
||||
120
src/commands/usage/__tests__/usage.test.ts
Normal file
120
src/commands/usage/__tests__/usage.test.ts
Normal file
@@ -0,0 +1,120 @@
|
||||
/**
|
||||
* Regression tests for /usage command — v2.1.118 upstream alignment.
|
||||
* Verifies:
|
||||
* - /usage is primary command with aliases ["cost", "stats"]
|
||||
* - description covers cost + stats
|
||||
* - availability restriction removed (not claude-ai only)
|
||||
* - cost/stats index files emit commands with matching name
|
||||
*/
|
||||
|
||||
import { mock, describe, test, expect } from 'bun:test'
|
||||
|
||||
// Must mock before importing anything that pulls in bootstrap/state
|
||||
import { logMock } from '../../../../tests/mocks/log.js'
|
||||
mock.module('src/utils/log.ts', logMock)
|
||||
|
||||
import { debugMock } from '../../../../tests/mocks/debug.js'
|
||||
mock.module('src/utils/debug.ts', debugMock)
|
||||
|
||||
mock.module('bun:bundle', () => ({ feature: () => false }))
|
||||
|
||||
mock.module('src/utils/auth.ts', () => ({
|
||||
isClaudeAISubscriber: () => false,
|
||||
getOAuthAccount: () => null,
|
||||
}))
|
||||
|
||||
mock.module('src/services/claudeAiLimits.ts', () => ({
|
||||
currentLimits: { isUsingOverage: false },
|
||||
}))
|
||||
|
||||
mock.module('src/cost-tracker.ts', () => ({
|
||||
formatTotalCost: () => 'Total cost: $0.0012',
|
||||
}))
|
||||
|
||||
mock.module('src/utils/config.ts', () => ({
|
||||
getCurrentProjectConfig: () => ({}),
|
||||
saveCurrentProjectConfig: () => {},
|
||||
getGlobalConfig: () => ({}),
|
||||
}))
|
||||
|
||||
// ── helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
async function loadUsageCommand() {
|
||||
const mod = await import('../index.js')
|
||||
return mod.default
|
||||
}
|
||||
|
||||
// ── tests ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('usage command — metadata', () => {
|
||||
test('name is "usage"', async () => {
|
||||
const cmd = await loadUsageCommand()
|
||||
expect(cmd.name).toBe('usage')
|
||||
})
|
||||
|
||||
test('has aliases containing "cost"', async () => {
|
||||
const cmd = await loadUsageCommand()
|
||||
expect(cmd.aliases?.includes('cost')).toBe(true)
|
||||
})
|
||||
|
||||
test('has aliases containing "stats"', async () => {
|
||||
const cmd = await loadUsageCommand()
|
||||
expect(cmd.aliases?.includes('stats')).toBe(true)
|
||||
})
|
||||
|
||||
test('has exactly two aliases', async () => {
|
||||
const cmd = await loadUsageCommand()
|
||||
expect(cmd.aliases?.length).toBe(2)
|
||||
})
|
||||
|
||||
test('aliases are ["cost", "stats"] in that order', async () => {
|
||||
const cmd = await loadUsageCommand()
|
||||
expect(cmd.aliases).toEqual(['cost', 'stats'])
|
||||
})
|
||||
|
||||
test('description mentions cost', async () => {
|
||||
const cmd = await loadUsageCommand()
|
||||
expect(cmd.description.toLowerCase()).toContain('cost')
|
||||
})
|
||||
|
||||
test('description mentions stat', async () => {
|
||||
const cmd = await loadUsageCommand()
|
||||
expect(cmd.description.toLowerCase()).toContain('stat')
|
||||
})
|
||||
|
||||
test('is NOT restricted exclusively to claude-ai subscribers', async () => {
|
||||
const cmd = await loadUsageCommand()
|
||||
const avail = (cmd as { availability?: string[] }).availability
|
||||
const isExclusivelyClaudeAi =
|
||||
Array.isArray(avail) && avail.length === 1 && avail[0] === 'claude-ai'
|
||||
expect(isExclusivelyClaudeAi).toBe(false)
|
||||
})
|
||||
|
||||
test('description mentions usage or plan', async () => {
|
||||
const cmd = await loadUsageCommand()
|
||||
const desc = cmd.description.toLowerCase()
|
||||
expect(desc.includes('usage') || desc.includes('plan')).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('usage command — cost index is no longer standalone', () => {
|
||||
test('cost/index default name is "usage" (delegated) OR it has aliases', async () => {
|
||||
const mod = await import('../../cost/index.js')
|
||||
const cmd = mod.default
|
||||
// After the fix: cost/index either exports name='usage' with aliases,
|
||||
// or the cost command has aliases set (it's been demoted to alias)
|
||||
const isUnifiedOrAliased =
|
||||
cmd.name === 'usage' || (cmd.aliases?.includes('cost') ?? false)
|
||||
expect(isUnifiedOrAliased).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('usage command — stats index is no longer standalone', () => {
|
||||
test('stats/index default name is "usage" (delegated) OR it has aliases', async () => {
|
||||
const mod = await import('../../stats/index.js')
|
||||
const cmd = mod.default
|
||||
const isUnifiedOrAliased =
|
||||
cmd.name === 'usage' || (cmd.aliases?.includes('stats') ?? false)
|
||||
expect(isUnifiedOrAliased).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -3,7 +3,7 @@ import type { Command } from '../../commands.js'
|
||||
export default {
|
||||
type: 'local-jsx',
|
||||
name: 'usage',
|
||||
description: 'Show plan usage limits',
|
||||
availability: ['claude-ai'],
|
||||
aliases: ['cost', 'stats'],
|
||||
description: 'Show session cost, plan usage, and activity stats',
|
||||
load: () => import('./usage.js'),
|
||||
} satisfies Command
|
||||
|
||||
@@ -1,6 +1,16 @@
|
||||
import { Settings } from '../../components/Settings/Settings.js';
|
||||
import type { LocalJSXCommandCall } from '../../types/command.js';
|
||||
|
||||
/**
|
||||
* /usage — unified command replacing /cost and /stats (v2.1.118 upstream alignment).
|
||||
*
|
||||
* Routing:
|
||||
* - claude.ai subscriber → Settings panel → Usage tab (plan limits + overages)
|
||||
* - API / non-subscriber → Stats panel (session cost, token counts, activity)
|
||||
*
|
||||
* Both /cost and /stats are registered as aliases of this command so that
|
||||
* existing muscle-memory still works.
|
||||
*/
|
||||
export const call: LocalJSXCommandCall = async (onDone, context) => {
|
||||
return <Settings onClose={onDone} context={context} defaultTab="Usage" />;
|
||||
};
|
||||
|
||||
@@ -14,7 +14,9 @@ const version = {
|
||||
name: 'version',
|
||||
description:
|
||||
'Print the version this session is running (not what autoupdate downloaded)',
|
||||
isEnabled: () => process.env.USER_TYPE === 'ant',
|
||||
// Was Ant-only upstream; for fork subscribers we want this universally
|
||||
// available — version info is harmless and useful for bug reports.
|
||||
isEnabled: () => true,
|
||||
supportsNonInteractive: true,
|
||||
load: () => Promise.resolve({ call }),
|
||||
} satisfies Command
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import axios from 'axios';
|
||||
import chalk from 'chalk';
|
||||
import { randomUUID } from 'crypto';
|
||||
import React from 'react';
|
||||
import { getOriginalCwd, getSessionId } from 'src/bootstrap/state.js';
|
||||
import { checkGate_CACHED_OR_BLOCKING } from 'src/services/analytics/growthbook.js';
|
||||
import {
|
||||
@@ -877,6 +878,13 @@ export async function teleportToRemote(options: {
|
||||
* identify the PR associated with this session.
|
||||
*/
|
||||
githubPr?: { owner: string; repo: string; number: number };
|
||||
/**
|
||||
* Identifies which command/flow originated this teleport. CCR backend
|
||||
* uses this for routing/observability. Known values: 'autofix_pr',
|
||||
* 'ultrareview', 'ultraplan'. Pass-through field — not interpreted
|
||||
* client-side; if backend doesn't recognize it, it's silently ignored.
|
||||
*/
|
||||
source?: string;
|
||||
}): Promise<TeleportToRemoteResponse | null> {
|
||||
const { initialMessage, signal } = options;
|
||||
try {
|
||||
@@ -1227,6 +1235,7 @@ export async function teleportToRemote(options: {
|
||||
model: options.model ?? getMainLoopModel(),
|
||||
...(options.reuseOutcomeBranch && { reuse_outcome_branches: true }),
|
||||
...(options.githubPr && { github_pr: options.githubPr }),
|
||||
...(options.source && { source: options.source }),
|
||||
};
|
||||
|
||||
// CreateCCRSessionPayload has no permission_mode field — a top-level
|
||||
|
||||
76
src/utils/teleport/__tests__/api.test.ts
Normal file
76
src/utils/teleport/__tests__/api.test.ts
Normal file
@@ -0,0 +1,76 @@
|
||||
/**
|
||||
* L2 regression tests for prepareWorkspaceApiRequest (codecov-100 audit #12):
|
||||
* pins the cleared-vs-never-set predicate that distinguishes the two error
|
||||
* messages.
|
||||
*
|
||||
* NOTE on isolation: several other test files in this repo
|
||||
* (`src/commands/vault/__tests__/api.test.ts`,
|
||||
* `src/commands/agents-platform/__tests__/agentsApi.test.ts`, etc.) call
|
||||
* `mock.module('src/utils/teleport/api.js', ...)` to stub
|
||||
* `prepareWorkspaceApiRequest`. Bun's mock registry is process-wide, so
|
||||
* full-suite imports of `../api.js` from this test file return the stubbed
|
||||
* module — we cannot exercise the real prepareWorkspaceApiRequest here.
|
||||
*
|
||||
* Workaround: we replicate the predicate logic from api.ts and pin it as
|
||||
* a pure unit test. The predicate is small and self-contained; if api.ts
|
||||
* ever changes the cleared-vs-never-set logic, both this replicated
|
||||
* function and the test must be updated together. End-to-end coverage of
|
||||
* the message text continues to come through the prepareWorkspaceApiRequest
|
||||
* call sites in the wider integration tests.
|
||||
*/
|
||||
import { describe, test, expect } from 'bun:test'
|
||||
|
||||
// ── Replicated from src/utils/teleport/api.ts (keep in sync) ────────────────
|
||||
// L2 fix: detect "was cleared" (null / empty / whitespace) vs "never set"
|
||||
// (undefined / missing field) so the user gets an actionable error message.
|
||||
function isWorkspaceKeyCleared(rawValue: unknown): boolean {
|
||||
return (
|
||||
rawValue === null ||
|
||||
(typeof rawValue === 'string' && rawValue.trim() === '')
|
||||
)
|
||||
}
|
||||
|
||||
describe('isWorkspaceKeyCleared (audit #12: cleared vs never-set predicate)', () => {
|
||||
test('undefined → not cleared (never set)', () => {
|
||||
expect(isWorkspaceKeyCleared(undefined)).toBe(false)
|
||||
})
|
||||
|
||||
test('missing field on config object → not cleared (never set)', () => {
|
||||
const config: { workspaceApiKey?: string | null } = {}
|
||||
expect(isWorkspaceKeyCleared(config.workspaceApiKey)).toBe(false)
|
||||
})
|
||||
|
||||
test('null → cleared', () => {
|
||||
expect(isWorkspaceKeyCleared(null)).toBe(true)
|
||||
})
|
||||
|
||||
test('empty string → cleared', () => {
|
||||
expect(isWorkspaceKeyCleared('')).toBe(true)
|
||||
})
|
||||
|
||||
test('whitespace-only string → cleared', () => {
|
||||
expect(isWorkspaceKeyCleared(' ')).toBe(true)
|
||||
expect(isWorkspaceKeyCleared('\t\n \r')).toBe(true)
|
||||
})
|
||||
|
||||
test('valid key string → not cleared', () => {
|
||||
expect(isWorkspaceKeyCleared('sk-ant-api03-validkey')).toBe(false)
|
||||
})
|
||||
|
||||
test('whitespace-padded valid key → not cleared (real prepare trims and uses it)', () => {
|
||||
// The function only tests the trimmed value; non-empty after trim
|
||||
// means a usable key exists, not a cleared one.
|
||||
expect(isWorkspaceKeyCleared(' sk-ant-api03-key ')).toBe(false)
|
||||
})
|
||||
|
||||
test('non-string non-null types are conservatively treated as not-cleared', () => {
|
||||
// Defensive: only literal null + empty/whitespace strings count as
|
||||
// "cleared". Other unexpected types fall through to the standard
|
||||
// "required" message rather than misleading the user with
|
||||
// "was cleared" when the underlying state is corrupt.
|
||||
expect(isWorkspaceKeyCleared(0)).toBe(false)
|
||||
expect(isWorkspaceKeyCleared(false)).toBe(false)
|
||||
expect(isWorkspaceKeyCleared({})).toBe(false)
|
||||
expect(isWorkspaceKeyCleared([])).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -4,6 +4,7 @@ import { getOauthConfig } from 'src/constants/oauth.js'
|
||||
import { getOrganizationUUID } from 'src/services/oauth/client.js'
|
||||
import z from 'zod/v4'
|
||||
import { getClaudeAIOAuthTokens } from '../auth.js'
|
||||
import { getGlobalConfig } from '../config.js'
|
||||
import { logForDebugging } from '../debug.js'
|
||||
import { parseGitHubRepository } from '../detectRepository.js'
|
||||
import { errorMessage, toError } from '../errors.js'
|
||||
@@ -174,6 +175,83 @@ export const CodeSessionSchema = lazySchema(() =>
|
||||
// Export the inferred type from the Zod schema
|
||||
export type CodeSession = z.infer<ReturnType<typeof CodeSessionSchema>>
|
||||
|
||||
/**
|
||||
* L2 fix (codecov-100 audit #12): predicate for "was the workspace API key
|
||||
* explicitly cleared" vs "was it never set". Treats workspaceApiKey
|
||||
* present-but-falsy (null, '', whitespace) as cleared, and absent
|
||||
* (undefined, missing field) as never-set. The TypeScript type is
|
||||
* `string | undefined` but the JSON file can legally hold null if a user
|
||||
* manually edited it, so we handle null defensively via runtime check.
|
||||
*
|
||||
* Other types (number, boolean, object, etc.) conservatively fall through
|
||||
* to "not cleared" — the underlying state is corrupt, and the standard
|
||||
* "required" message is less misleading than claiming the user cleared a
|
||||
* value they never set.
|
||||
*
|
||||
* Exported so unit tests can pin the predicate directly without needing
|
||||
* to bypass the process-wide mock.module() registrations on
|
||||
* `src/utils/teleport/api.js` from sibling test files.
|
||||
*/
|
||||
export function isWorkspaceKeyCleared(rawValue: unknown): boolean {
|
||||
return (
|
||||
rawValue === null ||
|
||||
(typeof rawValue === 'string' && rawValue.trim() === '')
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates and prepares for workspace API key requests (agents, vaults, memory_stores, skills).
|
||||
*
|
||||
* Reads the workspace API key from two sources in priority order:
|
||||
* 1. ANTHROPIC_API_KEY environment variable (takes precedence)
|
||||
* 2. workspaceApiKey field in ~/.claude.json (set via /login UI, no restart needed)
|
||||
*
|
||||
* Validates the sk-ant-api03-* prefix and returns the key for use in `x-api-key` headers.
|
||||
* Configuration errors (missing or wrong-prefix key) are surfaced as thrown errors so
|
||||
* callers can convert them to 501.
|
||||
*
|
||||
* @throws {Error} when no workspace key is found in env or settings, or the key does not
|
||||
* start with sk-ant-api03-
|
||||
*/
|
||||
export async function prepareWorkspaceApiRequest(): Promise<{
|
||||
apiKey: string
|
||||
}> {
|
||||
// Dual-source: env var takes precedence, then settings (saved via /login UI)
|
||||
const config = getGlobalConfig()
|
||||
const apiKey =
|
||||
process.env['ANTHROPIC_API_KEY']?.trim() || config.workspaceApiKey?.trim()
|
||||
|
||||
if (!apiKey) {
|
||||
// L2 fix (codecov-100 audit #12): when the user previously had a
|
||||
// workspace key and explicitly cleared it (set to null/empty), the
|
||||
// generic "required" error doesn't tell them what changed. Detect
|
||||
// the cleared-vs-never-set distinction so the prompt is actionable.
|
||||
const rawValue = (config as { workspaceApiKey?: string | null })
|
||||
.workspaceApiKey
|
||||
const wasCleared = isWorkspaceKeyCleared(rawValue)
|
||||
const preface = wasCleared
|
||||
? 'Your workspace API key was cleared. '
|
||||
: 'A workspace API key (sk-ant-api03-*) is required to use workspace endpoints ' +
|
||||
'(/v1/agents, /v1/vaults, /v1/memory_stores, /v1/skills). '
|
||||
throw new Error(
|
||||
preface +
|
||||
'Press W in /login to save your key directly (no restart needed), or ' +
|
||||
'set ANTHROPIC_API_KEY=<key> and restart. ' +
|
||||
'Obtain a key from https://console.anthropic.com/settings/keys. ' +
|
||||
'Subscription OAuth (claude.ai login) cannot reach these endpoints.',
|
||||
)
|
||||
}
|
||||
if (!apiKey.startsWith('sk-ant-api03-')) {
|
||||
// D5: expose at most first 4 chars to avoid leaking high-entropy secret bits into error logs/reports
|
||||
throw new Error(
|
||||
`Workspace API key must start with sk-ant-api03-, got prefix "${apiKey.slice(0, 4)}...". ` +
|
||||
'Obtain a workspace API key from https://console.anthropic.com/settings/keys. ' +
|
||||
'Press W in /login to save your key, or set ANTHROPIC_API_KEY.',
|
||||
)
|
||||
}
|
||||
return { apiKey }
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates and prepares for API requests
|
||||
* @returns Object containing access token and organization UUID
|
||||
|
||||
Reference in New Issue
Block a user