chore: initialize recovered claude workspace
This commit is contained in:
126
src/commands/add-dir/add-dir.tsx
Normal file
126
src/commands/add-dir/add-dir.tsx
Normal file
File diff suppressed because one or more lines are too long
11
src/commands/add-dir/index.ts
Normal file
11
src/commands/add-dir/index.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import type { Command } from '../../commands.js'
|
||||
|
||||
const addDir = {
|
||||
type: 'local-jsx',
|
||||
name: 'add-dir',
|
||||
description: 'Add a new working directory',
|
||||
argumentHint: '<path>',
|
||||
load: () => import('./add-dir.js'),
|
||||
} satisfies Command
|
||||
|
||||
export default addDir
|
||||
110
src/commands/add-dir/validation.ts
Normal file
110
src/commands/add-dir/validation.ts
Normal file
@@ -0,0 +1,110 @@
|
||||
import chalk from 'chalk'
|
||||
import { stat } from 'fs/promises'
|
||||
import { dirname, resolve } from 'path'
|
||||
import type { ToolPermissionContext } from '../../Tool.js'
|
||||
import { getErrnoCode } from '../../utils/errors.js'
|
||||
import { expandPath } from '../../utils/path.js'
|
||||
import {
|
||||
allWorkingDirectories,
|
||||
pathInWorkingPath,
|
||||
} from '../../utils/permissions/filesystem.js'
|
||||
|
||||
export type AddDirectoryResult =
|
||||
| {
|
||||
resultType: 'success'
|
||||
absolutePath: string
|
||||
}
|
||||
| {
|
||||
resultType: 'emptyPath'
|
||||
}
|
||||
| {
|
||||
resultType: 'pathNotFound' | 'notADirectory'
|
||||
directoryPath: string
|
||||
absolutePath: string
|
||||
}
|
||||
| {
|
||||
resultType: 'alreadyInWorkingDirectory'
|
||||
directoryPath: string
|
||||
workingDir: string
|
||||
}
|
||||
|
||||
export async function validateDirectoryForWorkspace(
|
||||
directoryPath: string,
|
||||
permissionContext: ToolPermissionContext,
|
||||
): Promise<AddDirectoryResult> {
|
||||
if (!directoryPath) {
|
||||
return {
|
||||
resultType: 'emptyPath',
|
||||
}
|
||||
}
|
||||
|
||||
// resolve() strips the trailing slash expandPath can leave on absolute
|
||||
// inputs, so /foo and /foo/ map to the same storage key (CC-33).
|
||||
const absolutePath = resolve(expandPath(directoryPath))
|
||||
|
||||
// Check if path exists and is a directory (single syscall)
|
||||
try {
|
||||
const stats = await stat(absolutePath)
|
||||
if (!stats.isDirectory()) {
|
||||
return {
|
||||
resultType: 'notADirectory',
|
||||
directoryPath,
|
||||
absolutePath,
|
||||
}
|
||||
}
|
||||
} catch (e: unknown) {
|
||||
const code = getErrnoCode(e)
|
||||
// Match prior existsSync() semantics: treat any of these as "not found"
|
||||
// rather than re-throwing. EACCES/EPERM in particular must not crash
|
||||
// startup when a settings-configured additional directory is inaccessible.
|
||||
if (
|
||||
code === 'ENOENT' ||
|
||||
code === 'ENOTDIR' ||
|
||||
code === 'EACCES' ||
|
||||
code === 'EPERM'
|
||||
) {
|
||||
return {
|
||||
resultType: 'pathNotFound',
|
||||
directoryPath,
|
||||
absolutePath,
|
||||
}
|
||||
}
|
||||
throw e
|
||||
}
|
||||
|
||||
// Get current permission context
|
||||
const currentWorkingDirs = allWorkingDirectories(permissionContext)
|
||||
|
||||
// Check if already within an existing working directory
|
||||
for (const workingDir of currentWorkingDirs) {
|
||||
if (pathInWorkingPath(absolutePath, workingDir)) {
|
||||
return {
|
||||
resultType: 'alreadyInWorkingDirectory',
|
||||
directoryPath,
|
||||
workingDir,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
resultType: 'success',
|
||||
absolutePath,
|
||||
}
|
||||
}
|
||||
|
||||
export function addDirHelpMessage(result: AddDirectoryResult): string {
|
||||
switch (result.resultType) {
|
||||
case 'emptyPath':
|
||||
return 'Please provide a directory path.'
|
||||
case 'pathNotFound':
|
||||
return `Path ${chalk.bold(result.absolutePath)} was not found.`
|
||||
case 'notADirectory': {
|
||||
const parentDir = dirname(result.absolutePath)
|
||||
return `${chalk.bold(result.directoryPath)} is not a directory. Did you mean to add the parent directory ${chalk.bold(parentDir)}?`
|
||||
}
|
||||
case 'alreadyInWorkingDirectory':
|
||||
return `${chalk.bold(result.directoryPath)} is already accessible within the existing working directory ${chalk.bold(result.workingDir)}.`
|
||||
case 'success':
|
||||
return `Added ${chalk.bold(result.absolutePath)} as a working directory.`
|
||||
}
|
||||
}
|
||||
109
src/commands/advisor.ts
Normal file
109
src/commands/advisor.ts
Normal file
@@ -0,0 +1,109 @@
|
||||
import type { Command } from '../commands.js'
|
||||
import type { LocalCommandCall } from '../types/command.js'
|
||||
import {
|
||||
canUserConfigureAdvisor,
|
||||
isValidAdvisorModel,
|
||||
modelSupportsAdvisor,
|
||||
} from '../utils/advisor.js'
|
||||
import {
|
||||
getDefaultMainLoopModelSetting,
|
||||
normalizeModelStringForAPI,
|
||||
parseUserSpecifiedModel,
|
||||
} from '../utils/model/model.js'
|
||||
import { validateModel } from '../utils/model/validateModel.js'
|
||||
import { updateSettingsForSource } from '../utils/settings/settings.js'
|
||||
|
||||
const call: LocalCommandCall = async (args, context) => {
|
||||
const arg = args.trim().toLowerCase()
|
||||
const baseModel = parseUserSpecifiedModel(
|
||||
context.getAppState().mainLoopModel ?? getDefaultMainLoopModelSetting(),
|
||||
)
|
||||
|
||||
if (!arg) {
|
||||
const current = context.getAppState().advisorModel
|
||||
if (!current) {
|
||||
return {
|
||||
type: 'text',
|
||||
value:
|
||||
'Advisor: not set\nUse "/advisor <model>" to enable (e.g. "/advisor opus").',
|
||||
}
|
||||
}
|
||||
if (!modelSupportsAdvisor(baseModel)) {
|
||||
return {
|
||||
type: 'text',
|
||||
value: `Advisor: ${current} (inactive)\nThe current model (${baseModel}) does not support advisors.`,
|
||||
}
|
||||
}
|
||||
return {
|
||||
type: 'text',
|
||||
value: `Advisor: ${current}\nUse "/advisor unset" to disable or "/advisor <model>" to change.`,
|
||||
}
|
||||
}
|
||||
|
||||
if (arg === 'unset' || arg === 'off') {
|
||||
const prev = context.getAppState().advisorModel
|
||||
context.setAppState(s => {
|
||||
if (s.advisorModel === undefined) return s
|
||||
return { ...s, advisorModel: undefined }
|
||||
})
|
||||
updateSettingsForSource('userSettings', { advisorModel: undefined })
|
||||
return {
|
||||
type: 'text',
|
||||
value: prev
|
||||
? `Advisor disabled (was ${prev}).`
|
||||
: 'Advisor already unset.',
|
||||
}
|
||||
}
|
||||
|
||||
const normalizedModel = normalizeModelStringForAPI(arg)
|
||||
const resolvedModel = parseUserSpecifiedModel(arg)
|
||||
const { valid, error } = await validateModel(resolvedModel)
|
||||
if (!valid) {
|
||||
return {
|
||||
type: 'text',
|
||||
value: error
|
||||
? `Invalid advisor model: ${error}`
|
||||
: `Unknown model: ${arg} (${resolvedModel})`,
|
||||
}
|
||||
}
|
||||
|
||||
if (!isValidAdvisorModel(resolvedModel)) {
|
||||
return {
|
||||
type: 'text',
|
||||
value: `The model ${arg} (${resolvedModel}) cannot be used as an advisor`,
|
||||
}
|
||||
}
|
||||
|
||||
context.setAppState(s => {
|
||||
if (s.advisorModel === normalizedModel) return s
|
||||
return { ...s, advisorModel: normalizedModel }
|
||||
})
|
||||
updateSettingsForSource('userSettings', { advisorModel: normalizedModel })
|
||||
|
||||
if (!modelSupportsAdvisor(baseModel)) {
|
||||
return {
|
||||
type: 'text',
|
||||
value: `Advisor set to ${normalizedModel}.\nNote: Your current model (${baseModel}) does not support advisors. Switch to a supported model to use the advisor.`,
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
type: 'text',
|
||||
value: `Advisor set to ${normalizedModel}.`,
|
||||
}
|
||||
}
|
||||
|
||||
const advisor = {
|
||||
type: 'local',
|
||||
name: 'advisor',
|
||||
description: 'Configure the advisor model',
|
||||
argumentHint: '[<model>|off]',
|
||||
isEnabled: () => canUserConfigureAdvisor(),
|
||||
get isHidden() {
|
||||
return !canUserConfigureAdvisor()
|
||||
},
|
||||
supportsNonInteractive: true,
|
||||
load: () => Promise.resolve({ call }),
|
||||
} satisfies Command
|
||||
|
||||
export default advisor
|
||||
12
src/commands/agents/agents.tsx
Normal file
12
src/commands/agents/agents.tsx
Normal file
@@ -0,0 +1,12 @@
|
||||
import * as React from 'react';
|
||||
import { AgentsMenu } from '../../components/agents/AgentsMenu.js';
|
||||
import type { ToolUseContext } from '../../Tool.js';
|
||||
import { getTools } from '../../tools.js';
|
||||
import type { LocalJSXCommandOnDone } from '../../types/command.js';
|
||||
export async function call(onDone: LocalJSXCommandOnDone, context: ToolUseContext): Promise<React.ReactNode> {
|
||||
const appState = context.getAppState();
|
||||
const permissionContext = appState.toolPermissionContext;
|
||||
const tools = getTools(permissionContext);
|
||||
return <AgentsMenu tools={tools} onExit={onDone} />;
|
||||
}
|
||||
//# sourceMappingURL=data:application/json;charset=utf-8;base64,eyJ2ZXJzaW9uIjozLCJuYW1lcyI6WyJSZWFjdCIsIkFnZW50c01lbnUiLCJUb29sVXNlQ29udGV4dCIsImdldFRvb2xzIiwiTG9jYWxKU1hDb21tYW5kT25Eb25lIiwiY2FsbCIsIm9uRG9uZSIsImNvbnRleHQiLCJQcm9taXNlIiwiUmVhY3ROb2RlIiwiYXBwU3RhdGUiLCJnZXRBcHBTdGF0ZSIsInBlcm1pc3Npb25Db250ZXh0IiwidG9vbFBlcm1pc3Npb25Db250ZXh0IiwidG9vbHMiXSwic291cmNlcyI6WyJhZ2VudHMudHN4Il0sInNvdXJjZXNDb250ZW50IjpbImltcG9ydCAqIGFzIFJlYWN0IGZyb20gJ3JlYWN0J1xuaW1wb3J0IHsgQWdlbnRzTWVudSB9IGZyb20gJy4uLy4uL2NvbXBvbmVudHMvYWdlbnRzL0FnZW50c01lbnUuanMnXG5pbXBvcnQgdHlwZSB7IFRvb2xVc2VDb250ZXh0IH0gZnJvbSAnLi4vLi4vVG9vbC5qcydcbmltcG9ydCB7IGdldFRvb2xzIH0gZnJvbSAnLi4vLi4vdG9vbHMuanMnXG5pbXBvcnQgdHlwZSB7IExvY2FsSlNYQ29tbWFuZE9uRG9uZSB9IGZyb20gJy4uLy4uL3R5cGVzL2NvbW1hbmQuanMnXG5cbmV4cG9ydCBhc3luYyBmdW5jdGlvbiBjYWxsKFxuICBvbkRvbmU6IExvY2FsSlNYQ29tbWFuZE9uRG9uZSxcbiAgY29udGV4dDogVG9vbFVzZUNvbnRleHQsXG4pOiBQcm9taXNlPFJlYWN0LlJlYWN0Tm9kZT4ge1xuICBjb25zdCBhcHBTdGF0ZSA9IGNvbnRleHQuZ2V0QXBwU3RhdGUoKVxuICBjb25zdCBwZXJtaXNzaW9uQ29udGV4dCA9IGFwcFN0YXRlLnRvb2xQZXJtaXNzaW9uQ29udGV4dFxuICBjb25zdCB0b29scyA9IGdldFRvb2xzKHBlcm1pc3Npb25Db250ZXh0KVxuXG4gIHJldHVybiA8QWdlbnRzTWVudSB0b29scz17dG9vbHN9IG9uRXhpdD17b25Eb25lfSAvPlxufVxuIl0sIm1hcHBpbmdzIjoiQUFBQSxPQUFPLEtBQUtBLEtBQUssTUFBTSxPQUFPO0FBQzlCLFNBQVNDLFVBQVUsUUFBUSx1Q0FBdUM7QUFDbEUsY0FBY0MsY0FBYyxRQUFRLGVBQWU7QUFDbkQsU0FBU0MsUUFBUSxRQUFRLGdCQUFnQjtBQUN6QyxjQUFjQyxxQkFBcUIsUUFBUSx3QkFBd0I7QUFFbkUsT0FBTyxlQUFlQyxJQUFJQSxDQUN4QkMsTUFBTSxFQUFFRixxQkFBcUIsRUFDN0JHLE9BQU8sRUFBRUwsY0FBYyxDQUN4QixFQUFFTSxPQUFPLENBQUNSLEtBQUssQ0FBQ1MsU0FBUyxDQUFDLENBQUM7RUFDMUIsTUFBTUMsUUFBUSxHQUFHSCxPQUFPLENBQUNJLFdBQVcsQ0FBQyxDQUFDO0VBQ3RDLE1BQU1DLGlCQUFpQixHQUFHRixRQUFRLENBQUNHLHFCQUFxQjtFQUN4RCxNQUFNQyxLQUFLLEdBQUdYLFFBQVEsQ0FBQ1MsaUJBQWlCLENBQUM7RUFFekMsT0FBTyxDQUFDLFVBQVUsQ0FBQyxLQUFLLENBQUMsQ0FBQ0UsS0FBSyxDQUFDLENBQUMsTUFBTSxDQUFDLENBQUNSLE1BQU0sQ0FBQyxHQUFHO0FBQ3JEIiwiaWdub3JlTGlzdCI6W119
|
||||
10
src/commands/agents/index.ts
Normal file
10
src/commands/agents/index.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import type { Command } from '../../commands.js'
|
||||
|
||||
const agents = {
|
||||
type: 'local-jsx',
|
||||
name: 'agents',
|
||||
description: 'Manage agent configurations',
|
||||
load: () => import('./agents.js'),
|
||||
} satisfies Command
|
||||
|
||||
export default agents
|
||||
1
src/commands/ant-trace/index.js
Normal file
1
src/commands/ant-trace/index.js
Normal file
@@ -0,0 +1 @@
|
||||
export default { isEnabled: () => false, isHidden: true, name: 'stub' };
|
||||
22
src/commands/assistant/assistant.tsx
Executable file
22
src/commands/assistant/assistant.tsx
Executable file
@@ -0,0 +1,22 @@
|
||||
import { homedir } from 'os'
|
||||
import { join } from 'path'
|
||||
import { useEffect } from 'react'
|
||||
|
||||
type Props = {
|
||||
defaultDir: string
|
||||
onInstalled: (dir: string) => void
|
||||
onCancel: () => void
|
||||
onError: (message: string) => void
|
||||
}
|
||||
|
||||
export async function computeDefaultInstallDir(): Promise<string> {
|
||||
return join(homedir(), '.claude', 'assistant')
|
||||
}
|
||||
|
||||
export function NewInstallWizard({ onCancel }: Props) {
|
||||
useEffect(() => {
|
||||
onCancel()
|
||||
}, [onCancel])
|
||||
|
||||
return null
|
||||
}
|
||||
1
src/commands/autofix-pr/index.js
Normal file
1
src/commands/autofix-pr/index.js
Normal file
@@ -0,0 +1 @@
|
||||
export default { isEnabled: () => false, isHidden: true, name: 'stub' };
|
||||
1
src/commands/backfill-sessions/index.js
Normal file
1
src/commands/backfill-sessions/index.js
Normal file
@@ -0,0 +1 @@
|
||||
export default { isEnabled: () => false, isHidden: true, name: 'stub' };
|
||||
296
src/commands/branch/branch.ts
Normal file
296
src/commands/branch/branch.ts
Normal file
@@ -0,0 +1,296 @@
|
||||
import { randomUUID, type UUID } from 'crypto'
|
||||
import { mkdir, readFile, writeFile } from 'fs/promises'
|
||||
import { getOriginalCwd, getSessionId } from '../../bootstrap/state.js'
|
||||
import type { LocalJSXCommandContext } from '../../commands.js'
|
||||
import { logEvent } from '../../services/analytics/index.js'
|
||||
import type { LocalJSXCommandOnDone } from '../../types/command.js'
|
||||
import type {
|
||||
ContentReplacementEntry,
|
||||
Entry,
|
||||
LogOption,
|
||||
SerializedMessage,
|
||||
TranscriptMessage,
|
||||
} from '../../types/logs.js'
|
||||
import { parseJSONL } from '../../utils/json.js'
|
||||
import {
|
||||
getProjectDir,
|
||||
getTranscriptPath,
|
||||
getTranscriptPathForSession,
|
||||
isTranscriptMessage,
|
||||
saveCustomTitle,
|
||||
searchSessionsByCustomTitle,
|
||||
} from '../../utils/sessionStorage.js'
|
||||
import { jsonStringify } from '../../utils/slowOperations.js'
|
||||
import { escapeRegExp } from '../../utils/stringUtils.js'
|
||||
|
||||
type TranscriptEntry = TranscriptMessage & {
|
||||
forkedFrom?: {
|
||||
sessionId: string
|
||||
messageUuid: UUID
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Derive a single-line title base from the first user message.
|
||||
* Collapses whitespace — multiline first messages (pasted stacks, code)
|
||||
* otherwise flow into the saved title and break the resume hint.
|
||||
*/
|
||||
export function deriveFirstPrompt(
|
||||
firstUserMessage: Extract<SerializedMessage, { type: 'user' }> | undefined,
|
||||
): string {
|
||||
const content = firstUserMessage?.message?.content
|
||||
if (!content) return 'Branched conversation'
|
||||
const raw =
|
||||
typeof content === 'string'
|
||||
? content
|
||||
: content.find(
|
||||
(block): block is { type: 'text'; text: string } =>
|
||||
block.type === 'text',
|
||||
)?.text
|
||||
if (!raw) return 'Branched conversation'
|
||||
return (
|
||||
raw.replace(/\s+/g, ' ').trim().slice(0, 100) || 'Branched conversation'
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a fork of the current conversation by copying from the transcript file.
|
||||
* Preserves all original metadata (timestamps, gitBranch, etc.) while updating
|
||||
* sessionId and adding forkedFrom traceability.
|
||||
*/
|
||||
async function createFork(customTitle?: string): Promise<{
|
||||
sessionId: UUID
|
||||
title: string | undefined
|
||||
forkPath: string
|
||||
serializedMessages: SerializedMessage[]
|
||||
contentReplacementRecords: ContentReplacementEntry['replacements']
|
||||
}> {
|
||||
const forkSessionId = randomUUID() as UUID
|
||||
const originalSessionId = getSessionId()
|
||||
const projectDir = getProjectDir(getOriginalCwd())
|
||||
const forkSessionPath = getTranscriptPathForSession(forkSessionId)
|
||||
const currentTranscriptPath = getTranscriptPath()
|
||||
|
||||
// Ensure project directory exists
|
||||
await mkdir(projectDir, { recursive: true, mode: 0o700 })
|
||||
|
||||
// Read current transcript file
|
||||
let transcriptContent: Buffer
|
||||
try {
|
||||
transcriptContent = await readFile(currentTranscriptPath)
|
||||
} catch {
|
||||
throw new Error('No conversation to branch')
|
||||
}
|
||||
|
||||
if (transcriptContent.length === 0) {
|
||||
throw new Error('No conversation to branch')
|
||||
}
|
||||
|
||||
// Parse all transcript entries (messages + metadata entries like content-replacement)
|
||||
const entries = parseJSONL<Entry>(transcriptContent)
|
||||
|
||||
// Filter to only main conversation messages (exclude sidechains and non-message entries)
|
||||
const mainConversationEntries = entries.filter(
|
||||
(entry): entry is TranscriptMessage =>
|
||||
isTranscriptMessage(entry) && !entry.isSidechain,
|
||||
)
|
||||
|
||||
// Content-replacement entries for the original session. These record which
|
||||
// tool_result blocks were replaced with previews by the per-message budget.
|
||||
// Without them in the fork JSONL, `claude -r {forkId}` reconstructs state
|
||||
// with an empty replacements Map → previously-replaced results are classified
|
||||
// as FROZEN and sent as full content (prompt cache miss + permanent overage).
|
||||
// sessionId must be rewritten since loadTranscriptFile keys lookup by the
|
||||
// session's messages' sessionId.
|
||||
const contentReplacementRecords = entries
|
||||
.filter(
|
||||
(entry): entry is ContentReplacementEntry =>
|
||||
entry.type === 'content-replacement' &&
|
||||
entry.sessionId === originalSessionId,
|
||||
)
|
||||
.flatMap(entry => entry.replacements)
|
||||
|
||||
if (mainConversationEntries.length === 0) {
|
||||
throw new Error('No messages to branch')
|
||||
}
|
||||
|
||||
// Build forked entries with new sessionId and preserved metadata
|
||||
let parentUuid: UUID | null = null
|
||||
const lines: string[] = []
|
||||
const serializedMessages: SerializedMessage[] = []
|
||||
|
||||
for (const entry of mainConversationEntries) {
|
||||
// Create forked transcript entry preserving all original metadata
|
||||
const forkedEntry: TranscriptEntry = {
|
||||
...entry,
|
||||
sessionId: forkSessionId,
|
||||
parentUuid,
|
||||
isSidechain: false,
|
||||
forkedFrom: {
|
||||
sessionId: originalSessionId,
|
||||
messageUuid: entry.uuid,
|
||||
},
|
||||
}
|
||||
|
||||
// Build serialized message for LogOption
|
||||
const serialized: SerializedMessage = {
|
||||
...entry,
|
||||
sessionId: forkSessionId,
|
||||
}
|
||||
|
||||
serializedMessages.push(serialized)
|
||||
lines.push(jsonStringify(forkedEntry))
|
||||
if (entry.type !== 'progress') {
|
||||
parentUuid = entry.uuid
|
||||
}
|
||||
}
|
||||
|
||||
// Append content-replacement entry (if any) with the fork's sessionId.
|
||||
// Written as a SINGLE entry (same shape as insertContentReplacement) so
|
||||
// loadTranscriptFile's content-replacement branch picks it up.
|
||||
if (contentReplacementRecords.length > 0) {
|
||||
const forkedReplacementEntry: ContentReplacementEntry = {
|
||||
type: 'content-replacement',
|
||||
sessionId: forkSessionId,
|
||||
replacements: contentReplacementRecords,
|
||||
}
|
||||
lines.push(jsonStringify(forkedReplacementEntry))
|
||||
}
|
||||
|
||||
// Write the fork session file
|
||||
await writeFile(forkSessionPath, lines.join('\n') + '\n', {
|
||||
encoding: 'utf8',
|
||||
mode: 0o600,
|
||||
})
|
||||
|
||||
return {
|
||||
sessionId: forkSessionId,
|
||||
title: customTitle,
|
||||
forkPath: forkSessionPath,
|
||||
serializedMessages,
|
||||
contentReplacementRecords,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates a unique fork name by checking for collisions with existing session names.
|
||||
* If "baseName (Branch)" already exists, tries "baseName (Branch 2)", "baseName (Branch 3)", etc.
|
||||
*/
|
||||
async function getUniqueForkName(baseName: string): Promise<string> {
|
||||
const candidateName = `${baseName} (Branch)`
|
||||
|
||||
// Check if this exact name already exists
|
||||
const existingWithExactName = await searchSessionsByCustomTitle(
|
||||
candidateName,
|
||||
{ exact: true },
|
||||
)
|
||||
|
||||
if (existingWithExactName.length === 0) {
|
||||
return candidateName
|
||||
}
|
||||
|
||||
// Name collision - find a unique numbered suffix
|
||||
// Search for all sessions that start with the base pattern
|
||||
const existingForks = await searchSessionsByCustomTitle(`${baseName} (Branch`)
|
||||
|
||||
// Extract existing fork numbers to find the next available
|
||||
const usedNumbers = new Set<number>([1]) // Consider " (Branch)" as number 1
|
||||
const forkNumberPattern = new RegExp(
|
||||
`^${escapeRegExp(baseName)} \\(Branch(?: (\\d+))?\\)$`,
|
||||
)
|
||||
|
||||
for (const session of existingForks) {
|
||||
const match = session.customTitle?.match(forkNumberPattern)
|
||||
if (match) {
|
||||
if (match[1]) {
|
||||
usedNumbers.add(parseInt(match[1], 10))
|
||||
} else {
|
||||
usedNumbers.add(1) // " (Branch)" without number is treated as 1
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Find the next available number
|
||||
let nextNumber = 2
|
||||
while (usedNumbers.has(nextNumber)) {
|
||||
nextNumber++
|
||||
}
|
||||
|
||||
return `${baseName} (Branch ${nextNumber})`
|
||||
}
|
||||
|
||||
export async function call(
|
||||
onDone: LocalJSXCommandOnDone,
|
||||
context: LocalJSXCommandContext,
|
||||
args: string,
|
||||
): Promise<React.ReactNode> {
|
||||
const customTitle = args?.trim() || undefined
|
||||
|
||||
const originalSessionId = getSessionId()
|
||||
|
||||
try {
|
||||
const {
|
||||
sessionId,
|
||||
title,
|
||||
forkPath,
|
||||
serializedMessages,
|
||||
contentReplacementRecords,
|
||||
} = await createFork(customTitle)
|
||||
|
||||
// Build LogOption for resume
|
||||
const now = new Date()
|
||||
const firstPrompt = deriveFirstPrompt(
|
||||
serializedMessages.find(m => m.type === 'user'),
|
||||
)
|
||||
|
||||
// Save custom title - use provided title or firstPrompt as default
|
||||
// This ensures /status and /resume show the same session name
|
||||
// Always add " (Branch)" suffix to make it clear this is a branched session
|
||||
// Handle collisions by adding a number suffix (e.g., " (Branch 2)", " (Branch 3)")
|
||||
const baseName = title ?? firstPrompt
|
||||
const effectiveTitle = await getUniqueForkName(baseName)
|
||||
await saveCustomTitle(sessionId, effectiveTitle, forkPath)
|
||||
|
||||
logEvent('tengu_conversation_forked', {
|
||||
message_count: serializedMessages.length,
|
||||
has_custom_title: !!title,
|
||||
})
|
||||
|
||||
const forkLog: LogOption = {
|
||||
date: now.toISOString().split('T')[0]!,
|
||||
messages: serializedMessages,
|
||||
fullPath: forkPath,
|
||||
value: now.getTime(),
|
||||
created: now,
|
||||
modified: now,
|
||||
firstPrompt,
|
||||
messageCount: serializedMessages.length,
|
||||
isSidechain: false,
|
||||
sessionId,
|
||||
customTitle: effectiveTitle,
|
||||
contentReplacements: contentReplacementRecords,
|
||||
}
|
||||
|
||||
// Resume into the fork
|
||||
const titleInfo = title ? ` "${title}"` : ''
|
||||
const resumeHint = `\nTo resume the original: claude -r ${originalSessionId}`
|
||||
const successMessage = `Branched conversation${titleInfo}. You are now in the branch.${resumeHint}`
|
||||
|
||||
if (context.resume) {
|
||||
await context.resume(sessionId, forkLog, 'fork')
|
||||
onDone(successMessage, { display: 'system' })
|
||||
} else {
|
||||
// Fallback if resume not available
|
||||
onDone(
|
||||
`Branched conversation${titleInfo}. Resume with: /resume ${sessionId}`,
|
||||
)
|
||||
}
|
||||
|
||||
return null
|
||||
} catch (error) {
|
||||
const message =
|
||||
error instanceof Error ? error.message : 'Unknown error occurred'
|
||||
onDone(`Failed to branch conversation: ${message}`)
|
||||
return null
|
||||
}
|
||||
}
|
||||
14
src/commands/branch/index.ts
Normal file
14
src/commands/branch/index.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import { feature } from 'bun:bundle'
|
||||
import type { Command } from '../../commands.js'
|
||||
|
||||
const branch = {
|
||||
type: 'local-jsx',
|
||||
name: 'branch',
|
||||
// 'fork' alias only when /fork doesn't exist as its own command
|
||||
aliases: feature('FORK_SUBAGENT') ? [] : ['fork'],
|
||||
description: 'Create a branch of the current conversation at this point',
|
||||
argumentHint: '[name]',
|
||||
load: () => import('./branch.js'),
|
||||
} satisfies Command
|
||||
|
||||
export default branch
|
||||
1
src/commands/break-cache/index.js
Normal file
1
src/commands/break-cache/index.js
Normal file
@@ -0,0 +1 @@
|
||||
export default { isEnabled: () => false, isHidden: true, name: 'stub' };
|
||||
200
src/commands/bridge-kick.ts
Normal file
200
src/commands/bridge-kick.ts
Normal file
@@ -0,0 +1,200 @@
|
||||
import { getBridgeDebugHandle } from '../bridge/bridgeDebug.js'
|
||||
import type { Command } from '../commands.js'
|
||||
import type { LocalCommandCall } from '../types/command.js'
|
||||
|
||||
/**
|
||||
* Ant-only: inject bridge failure states to manually test recovery paths.
|
||||
*
|
||||
* /bridge-kick close 1002 — fire ws_closed with code 1002
|
||||
* /bridge-kick close 1006 — fire ws_closed with code 1006
|
||||
* /bridge-kick poll 404 — next poll throws 404/not_found_error
|
||||
* /bridge-kick poll 404 <type> — next poll throws 404 with error_type
|
||||
* /bridge-kick poll 401 — next poll throws 401 (auth)
|
||||
* /bridge-kick poll transient — next poll throws axios-style rejection
|
||||
* /bridge-kick register fail — next register (inside doReconnect) transient-fails
|
||||
* /bridge-kick register fail 3 — next 3 registers transient-fail
|
||||
* /bridge-kick register fatal — next register 403s (terminal)
|
||||
* /bridge-kick reconnect-session fail — POST /bridge/reconnect fails (→ Strategy 2)
|
||||
* /bridge-kick heartbeat 401 — next heartbeat 401s (JWT expired)
|
||||
* /bridge-kick reconnect — call doReconnect directly (= SIGUSR2)
|
||||
* /bridge-kick status — print current bridge state
|
||||
*
|
||||
* Workflow: connect Remote Control, run a subcommand, `tail -f debug.log`
|
||||
* and watch [bridge:repl] / [bridge:debug] lines for the recovery reaction.
|
||||
*
|
||||
* Composite sequences — the failure modes in the BQ data are chains, not
|
||||
* single events. Queue faults then fire the trigger:
|
||||
*
|
||||
* # #22148 residual: ws_closed → register transient-blips → teardown?
|
||||
* /bridge-kick register fail 2
|
||||
* /bridge-kick close 1002
|
||||
* → expect: doReconnect tries register, fails, returns false → teardown
|
||||
* (demonstrates the retry gap that needs fixing)
|
||||
*
|
||||
* # Dead gate: poll 404/not_found_error → does onEnvironmentLost fire?
|
||||
* /bridge-kick poll 404
|
||||
* → expect: tengu_bridge_repl_fatal_error (gate is dead — 147K/wk)
|
||||
* after fix: tengu_bridge_repl_env_lost → doReconnect
|
||||
*/
|
||||
|
||||
const USAGE = `/bridge-kick <subcommand>
|
||||
close <code> fire ws_closed with the given code (e.g. 1002)
|
||||
poll <status> [type] next poll throws BridgeFatalError(status, type)
|
||||
poll transient next poll throws axios-style rejection (5xx/net)
|
||||
register fail [N] next N registers transient-fail (default 1)
|
||||
register fatal next register 403s (terminal)
|
||||
reconnect-session fail next POST /bridge/reconnect fails
|
||||
heartbeat <status> next heartbeat throws BridgeFatalError(status)
|
||||
reconnect call reconnectEnvironmentWithSession directly
|
||||
status print bridge state`
|
||||
|
||||
const call: LocalCommandCall = async args => {
|
||||
const h = getBridgeDebugHandle()
|
||||
if (!h) {
|
||||
return {
|
||||
type: 'text',
|
||||
value:
|
||||
'No bridge debug handle registered. Remote Control must be connected (USER_TYPE=ant).',
|
||||
}
|
||||
}
|
||||
|
||||
const [sub, a, b] = args.trim().split(/\s+/)
|
||||
|
||||
switch (sub) {
|
||||
case 'close': {
|
||||
const code = Number(a)
|
||||
if (!Number.isFinite(code)) {
|
||||
return { type: 'text', value: `close: need a numeric code\n${USAGE}` }
|
||||
}
|
||||
h.fireClose(code)
|
||||
return {
|
||||
type: 'text',
|
||||
value: `Fired transport close(${code}). Watch debug.log for [bridge:repl] recovery.`,
|
||||
}
|
||||
}
|
||||
|
||||
case 'poll': {
|
||||
if (a === 'transient') {
|
||||
h.injectFault({
|
||||
method: 'pollForWork',
|
||||
kind: 'transient',
|
||||
status: 503,
|
||||
count: 1,
|
||||
})
|
||||
h.wakePollLoop()
|
||||
return {
|
||||
type: 'text',
|
||||
value:
|
||||
'Next poll will throw a transient (axios rejection). Poll loop woken.',
|
||||
}
|
||||
}
|
||||
const status = Number(a)
|
||||
if (!Number.isFinite(status)) {
|
||||
return {
|
||||
type: 'text',
|
||||
value: `poll: need 'transient' or a status code\n${USAGE}`,
|
||||
}
|
||||
}
|
||||
// Default to what the server ACTUALLY sends for 404 (BQ-verified),
|
||||
// so `/bridge-kick poll 404` reproduces the real 147K/week state.
|
||||
const errorType =
|
||||
b ?? (status === 404 ? 'not_found_error' : 'authentication_error')
|
||||
h.injectFault({
|
||||
method: 'pollForWork',
|
||||
kind: 'fatal',
|
||||
status,
|
||||
errorType,
|
||||
count: 1,
|
||||
})
|
||||
h.wakePollLoop()
|
||||
return {
|
||||
type: 'text',
|
||||
value: `Next poll will throw BridgeFatalError(${status}, ${errorType}). Poll loop woken.`,
|
||||
}
|
||||
}
|
||||
|
||||
case 'register': {
|
||||
if (a === 'fatal') {
|
||||
h.injectFault({
|
||||
method: 'registerBridgeEnvironment',
|
||||
kind: 'fatal',
|
||||
status: 403,
|
||||
errorType: 'permission_error',
|
||||
count: 1,
|
||||
})
|
||||
return {
|
||||
type: 'text',
|
||||
value:
|
||||
'Next registerBridgeEnvironment will 403. Trigger with close/reconnect.',
|
||||
}
|
||||
}
|
||||
const n = Number(b) || 1
|
||||
h.injectFault({
|
||||
method: 'registerBridgeEnvironment',
|
||||
kind: 'transient',
|
||||
status: 503,
|
||||
count: n,
|
||||
})
|
||||
return {
|
||||
type: 'text',
|
||||
value: `Next ${n} registerBridgeEnvironment call(s) will transient-fail. Trigger with close/reconnect.`,
|
||||
}
|
||||
}
|
||||
|
||||
case 'reconnect-session': {
|
||||
h.injectFault({
|
||||
method: 'reconnectSession',
|
||||
kind: 'fatal',
|
||||
status: 404,
|
||||
errorType: 'not_found_error',
|
||||
count: 2,
|
||||
})
|
||||
return {
|
||||
type: 'text',
|
||||
value:
|
||||
'Next 2 POST /bridge/reconnect calls will 404. doReconnect Strategy 1 falls through to Strategy 2.',
|
||||
}
|
||||
}
|
||||
|
||||
case 'heartbeat': {
|
||||
const status = Number(a) || 401
|
||||
h.injectFault({
|
||||
method: 'heartbeatWork',
|
||||
kind: 'fatal',
|
||||
status,
|
||||
errorType: status === 401 ? 'authentication_error' : 'not_found_error',
|
||||
count: 1,
|
||||
})
|
||||
return {
|
||||
type: 'text',
|
||||
value: `Next heartbeat will ${status}. Watch for onHeartbeatFatal → work-state teardown.`,
|
||||
}
|
||||
}
|
||||
|
||||
case 'reconnect': {
|
||||
h.forceReconnect()
|
||||
return {
|
||||
type: 'text',
|
||||
value: 'Called reconnectEnvironmentWithSession(). Watch debug.log.',
|
||||
}
|
||||
}
|
||||
|
||||
case 'status': {
|
||||
return { type: 'text', value: h.describe() }
|
||||
}
|
||||
|
||||
default:
|
||||
return { type: 'text', value: USAGE }
|
||||
}
|
||||
}
|
||||
|
||||
const bridgeKick = {
|
||||
type: 'local',
|
||||
name: 'bridge-kick',
|
||||
description: 'Inject bridge failure states for manual recovery testing',
|
||||
isEnabled: () => process.env.USER_TYPE === 'ant',
|
||||
supportsNonInteractive: false,
|
||||
load: () => Promise.resolve({ call }),
|
||||
} satisfies Command
|
||||
|
||||
export default bridgeKick
|
||||
509
src/commands/bridge/bridge.tsx
Normal file
509
src/commands/bridge/bridge.tsx
Normal file
File diff suppressed because one or more lines are too long
26
src/commands/bridge/index.ts
Normal file
26
src/commands/bridge/index.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import { feature } from 'bun:bundle'
|
||||
import { isBridgeEnabled } from '../../bridge/bridgeEnabled.js'
|
||||
import type { Command } from '../../commands.js'
|
||||
|
||||
function isEnabled(): boolean {
|
||||
if (!feature('BRIDGE_MODE')) {
|
||||
return false
|
||||
}
|
||||
return isBridgeEnabled()
|
||||
}
|
||||
|
||||
const bridge = {
|
||||
type: 'local-jsx',
|
||||
name: 'remote-control',
|
||||
aliases: ['rc'],
|
||||
description: 'Connect this terminal for remote-control sessions',
|
||||
argumentHint: '[name]',
|
||||
isEnabled,
|
||||
get isHidden() {
|
||||
return !isEnabled()
|
||||
},
|
||||
immediate: true,
|
||||
load: () => import('./bridge.js'),
|
||||
} satisfies Command
|
||||
|
||||
export default bridge
|
||||
130
src/commands/brief.ts
Normal file
130
src/commands/brief.ts
Normal file
@@ -0,0 +1,130 @@
|
||||
import { feature } from 'bun:bundle'
|
||||
import { z } from 'zod/v4'
|
||||
import { getKairosActive, setUserMsgOptIn } from '../bootstrap/state.js'
|
||||
import { getFeatureValue_CACHED_MAY_BE_STALE } from '../services/analytics/growthbook.js'
|
||||
import {
|
||||
type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
||||
logEvent,
|
||||
} from '../services/analytics/index.js'
|
||||
import type { ToolUseContext } from '../Tool.js'
|
||||
import { isBriefEntitled } from '../tools/BriefTool/BriefTool.js'
|
||||
import { BRIEF_TOOL_NAME } from '../tools/BriefTool/prompt.js'
|
||||
import type {
|
||||
Command,
|
||||
LocalJSXCommandContext,
|
||||
LocalJSXCommandOnDone,
|
||||
} from '../types/command.js'
|
||||
import { lazySchema } from '../utils/lazySchema.js'
|
||||
|
||||
// Zod guards against fat-fingered GB pushes (same pattern as pollConfig.ts /
|
||||
// cronScheduler.ts). A malformed config falls back to DEFAULT_BRIEF_CONFIG
|
||||
// entirely rather than being partially trusted.
|
||||
const briefConfigSchema = lazySchema(() =>
|
||||
z.object({
|
||||
enable_slash_command: z.boolean(),
|
||||
}),
|
||||
)
|
||||
type BriefConfig = z.infer<ReturnType<typeof briefConfigSchema>>
|
||||
|
||||
const DEFAULT_BRIEF_CONFIG: BriefConfig = {
|
||||
enable_slash_command: false,
|
||||
}
|
||||
|
||||
// No TTL — this gate controls slash-command *visibility*, not a kill switch.
|
||||
// CACHED_MAY_BE_STALE still has one background-update flip (first call kicks
|
||||
// off fetch; second call sees fresh value), but no additional flips after that.
|
||||
// The tool-availability gate (tengu_kairos_brief in isBriefEnabled) keeps its
|
||||
// 5-min TTL because that one IS a kill switch.
|
||||
function getBriefConfig(): BriefConfig {
|
||||
const raw = getFeatureValue_CACHED_MAY_BE_STALE<unknown>(
|
||||
'tengu_kairos_brief_config',
|
||||
DEFAULT_BRIEF_CONFIG,
|
||||
)
|
||||
const parsed = briefConfigSchema().safeParse(raw)
|
||||
return parsed.success ? parsed.data : DEFAULT_BRIEF_CONFIG
|
||||
}
|
||||
|
||||
const brief = {
|
||||
type: 'local-jsx',
|
||||
name: 'brief',
|
||||
description: 'Toggle brief-only mode',
|
||||
isEnabled: () => {
|
||||
if (feature('KAIROS') || feature('KAIROS_BRIEF')) {
|
||||
return getBriefConfig().enable_slash_command
|
||||
}
|
||||
return false
|
||||
},
|
||||
immediate: true,
|
||||
load: () =>
|
||||
Promise.resolve({
|
||||
async call(
|
||||
onDone: LocalJSXCommandOnDone,
|
||||
context: ToolUseContext & LocalJSXCommandContext,
|
||||
): Promise<React.ReactNode> {
|
||||
const current = context.getAppState().isBriefOnly
|
||||
const newState = !current
|
||||
|
||||
// Entitlement check only gates the on-transition — off is always
|
||||
// allowed so a user whose GB gate flipped mid-session isn't stuck.
|
||||
if (newState && !isBriefEntitled()) {
|
||||
logEvent('tengu_brief_mode_toggled', {
|
||||
enabled: false,
|
||||
gated: true,
|
||||
source:
|
||||
'slash_command' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
||||
})
|
||||
onDone('Brief tool is not enabled for your account', {
|
||||
display: 'system',
|
||||
})
|
||||
return null
|
||||
}
|
||||
|
||||
// Two-way: userMsgOptIn tracks isBriefOnly so the tool is available
|
||||
// exactly when brief mode is on. This invalidates prompt cache on
|
||||
// each toggle (tool list changes), but a stale tool list is worse —
|
||||
// when /brief is enabled mid-session the model was previously left
|
||||
// without the tool, emitting plain text the filter hides.
|
||||
setUserMsgOptIn(newState)
|
||||
|
||||
context.setAppState(prev => {
|
||||
if (prev.isBriefOnly === newState) return prev
|
||||
return { ...prev, isBriefOnly: newState }
|
||||
})
|
||||
|
||||
logEvent('tengu_brief_mode_toggled', {
|
||||
enabled: newState,
|
||||
gated: false,
|
||||
source:
|
||||
'slash_command' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
||||
})
|
||||
|
||||
// The tool list change alone isn't a strong enough signal mid-session
|
||||
// (model may keep emitting plain text from inertia, or keep calling a
|
||||
// tool that just vanished). Inject an explicit reminder into the next
|
||||
// turn's context so the transition is unambiguous.
|
||||
// Skip when Kairos is active: isBriefEnabled() short-circuits on
|
||||
// getKairosActive() so the tool never actually leaves the list, and
|
||||
// the Kairos system prompt already mandates SendUserMessage.
|
||||
// Inline <system-reminder> wrap — importing wrapInSystemReminder from
|
||||
// utils/messages.ts pulls constants/xml.ts into the bridge SDK bundle
|
||||
// via this module's import chain, tripping the excluded-strings check.
|
||||
const metaMessages = getKairosActive()
|
||||
? undefined
|
||||
: [
|
||||
`<system-reminder>\n${
|
||||
newState
|
||||
? `Brief mode is now enabled. Use the ${BRIEF_TOOL_NAME} tool for all user-facing output — plain text outside it is hidden from the user's view.`
|
||||
: `Brief mode is now disabled. The ${BRIEF_TOOL_NAME} tool is no longer available — reply with plain text.`
|
||||
}\n</system-reminder>`,
|
||||
]
|
||||
|
||||
onDone(
|
||||
newState ? 'Brief-only mode enabled' : 'Brief-only mode disabled',
|
||||
{ display: 'system', metaMessages },
|
||||
)
|
||||
return null
|
||||
},
|
||||
}),
|
||||
} satisfies Command
|
||||
|
||||
export default brief
|
||||
243
src/commands/btw/btw.tsx
Normal file
243
src/commands/btw/btw.tsx
Normal file
File diff suppressed because one or more lines are too long
13
src/commands/btw/index.ts
Normal file
13
src/commands/btw/index.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import type { Command } from '../../commands.js'
|
||||
|
||||
const btw = {
|
||||
type: 'local-jsx',
|
||||
name: 'btw',
|
||||
description:
|
||||
'Ask a quick side question without interrupting the main conversation',
|
||||
immediate: true,
|
||||
argumentHint: '<question>',
|
||||
load: () => import('./btw.js'),
|
||||
} satisfies Command
|
||||
|
||||
export default btw
|
||||
1
src/commands/bughunter/index.js
Normal file
1
src/commands/bughunter/index.js
Normal file
@@ -0,0 +1 @@
|
||||
export default { isEnabled: () => false, isHidden: true, name: 'stub' };
|
||||
285
src/commands/chrome/chrome.tsx
Normal file
285
src/commands/chrome/chrome.tsx
Normal file
File diff suppressed because one or more lines are too long
13
src/commands/chrome/index.ts
Normal file
13
src/commands/chrome/index.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import { getIsNonInteractiveSession } from '../../bootstrap/state.js'
|
||||
import type { Command } from '../../commands.js'
|
||||
|
||||
const command: Command = {
|
||||
name: 'chrome',
|
||||
description: 'Claude in Chrome (Beta) settings',
|
||||
availability: ['claude-ai'],
|
||||
isEnabled: () => !getIsNonInteractiveSession(),
|
||||
type: 'local-jsx',
|
||||
load: () => import('./chrome.js'),
|
||||
}
|
||||
|
||||
export default command
|
||||
144
src/commands/clear/caches.ts
Normal file
144
src/commands/clear/caches.ts
Normal file
@@ -0,0 +1,144 @@
|
||||
/**
|
||||
* Session cache clearing utilities.
|
||||
* This module is imported at startup by main.tsx, so keep imports minimal.
|
||||
*/
|
||||
import { feature } from 'bun:bundle'
|
||||
import {
|
||||
clearInvokedSkills,
|
||||
setLastEmittedDate,
|
||||
} from '../../bootstrap/state.js'
|
||||
import { clearCommandsCache } from '../../commands.js'
|
||||
import { getSessionStartDate } from '../../constants/common.js'
|
||||
import {
|
||||
getGitStatus,
|
||||
getSystemContext,
|
||||
getUserContext,
|
||||
setSystemPromptInjection,
|
||||
} from '../../context.js'
|
||||
import { clearFileSuggestionCaches } from '../../hooks/fileSuggestions.js'
|
||||
import { clearAllPendingCallbacks } from '../../hooks/useSwarmPermissionPoller.js'
|
||||
import { clearAllDumpState } from '../../services/api/dumpPrompts.js'
|
||||
import { resetPromptCacheBreakDetection } from '../../services/api/promptCacheBreakDetection.js'
|
||||
import { clearAllSessions } from '../../services/api/sessionIngress.js'
|
||||
import { runPostCompactCleanup } from '../../services/compact/postCompactCleanup.js'
|
||||
import { resetAllLSPDiagnosticState } from '../../services/lsp/LSPDiagnosticRegistry.js'
|
||||
import { clearTrackedMagicDocs } from '../../services/MagicDocs/magicDocs.js'
|
||||
import { clearDynamicSkills } from '../../skills/loadSkillsDir.js'
|
||||
import { resetSentSkillNames } from '../../utils/attachments.js'
|
||||
import { clearCommandPrefixCaches } from '../../utils/bash/commands.js'
|
||||
import { resetGetMemoryFilesCache } from '../../utils/claudemd.js'
|
||||
import { clearRepositoryCaches } from '../../utils/detectRepository.js'
|
||||
import { clearResolveGitDirCache } from '../../utils/git/gitFilesystem.js'
|
||||
import { clearStoredImagePaths } from '../../utils/imageStore.js'
|
||||
import { clearSessionEnvVars } from '../../utils/sessionEnvVars.js'
|
||||
|
||||
/**
|
||||
* Clear all session-related caches.
|
||||
* Call this when resuming a session to ensure fresh file/skill discovery.
|
||||
* This is a subset of what clearConversation does - it only clears caches
|
||||
* without affecting messages, session ID, or triggering hooks.
|
||||
*
|
||||
* @param preservedAgentIds - Agent IDs whose per-agent state should survive
|
||||
* the clear (e.g., background tasks preserved across /clear). When non-empty,
|
||||
* agentId-keyed state (invoked skills) is selectively cleared and requestId-keyed
|
||||
* state (pending permission callbacks, dump state, cache-break tracking) is left
|
||||
* intact since it cannot be safely scoped to the main session.
|
||||
*/
|
||||
export function clearSessionCaches(
|
||||
preservedAgentIds: ReadonlySet<string> = new Set(),
|
||||
): void {
|
||||
const hasPreserved = preservedAgentIds.size > 0
|
||||
// Clear context caches
|
||||
getUserContext.cache.clear?.()
|
||||
getSystemContext.cache.clear?.()
|
||||
getGitStatus.cache.clear?.()
|
||||
getSessionStartDate.cache.clear?.()
|
||||
// Clear file suggestion caches (for @ mentions)
|
||||
clearFileSuggestionCaches()
|
||||
|
||||
// Clear commands/skills cache
|
||||
clearCommandsCache()
|
||||
|
||||
// Clear prompt cache break detection state
|
||||
if (!hasPreserved) resetPromptCacheBreakDetection()
|
||||
|
||||
// Clear system prompt injection (cache breaker)
|
||||
setSystemPromptInjection(null)
|
||||
|
||||
// Clear last emitted date so it's re-detected on next turn
|
||||
setLastEmittedDate(null)
|
||||
|
||||
// Run post-compaction cleanup (clears system prompt sections, microcompact tracking,
|
||||
// classifier approvals, speculative checks, and — for main-thread compacts — memory
|
||||
// files cache with load_reason 'compact').
|
||||
runPostCompactCleanup()
|
||||
// Reset sent skill names so the skill listing is re-sent after /clear.
|
||||
// runPostCompactCleanup intentionally does NOT reset this (post-compact
|
||||
// re-injection costs ~4K tokens), but /clear wipes messages entirely so
|
||||
// the model needs the full listing again.
|
||||
resetSentSkillNames()
|
||||
// Override the memory cache reset with 'session_start': clearSessionCaches is called
|
||||
// from /clear and --resume/--continue, which are NOT compaction events. Without this,
|
||||
// the InstructionsLoaded hook would fire with load_reason 'compact' instead of
|
||||
// 'session_start' on the next getMemoryFiles() call.
|
||||
resetGetMemoryFilesCache('session_start')
|
||||
|
||||
// Clear stored image paths cache
|
||||
clearStoredImagePaths()
|
||||
|
||||
// Clear all session ingress caches (lastUuidMap, sequentialAppendBySession)
|
||||
clearAllSessions()
|
||||
// Clear swarm permission pending callbacks
|
||||
if (!hasPreserved) clearAllPendingCallbacks()
|
||||
|
||||
// Clear tungsten session usage tracking
|
||||
if (process.env.USER_TYPE === 'ant') {
|
||||
void import('../../tools/TungstenTool/TungstenTool.js').then(
|
||||
({ clearSessionsWithTungstenUsage, resetInitializationState }) => {
|
||||
clearSessionsWithTungstenUsage()
|
||||
resetInitializationState()
|
||||
},
|
||||
)
|
||||
}
|
||||
// Clear attribution caches (file content cache, pending bash states)
|
||||
// Dynamic import to preserve dead code elimination for COMMIT_ATTRIBUTION feature flag
|
||||
if (feature('COMMIT_ATTRIBUTION')) {
|
||||
void import('../../utils/attributionHooks.js').then(
|
||||
({ clearAttributionCaches }) => clearAttributionCaches(),
|
||||
)
|
||||
}
|
||||
// Clear repository detection caches
|
||||
clearRepositoryCaches()
|
||||
// Clear bash command prefix caches (Haiku-extracted prefixes)
|
||||
clearCommandPrefixCaches()
|
||||
// Clear dump prompts state
|
||||
if (!hasPreserved) clearAllDumpState()
|
||||
// Clear invoked skills cache (each entry holds full skill file content)
|
||||
clearInvokedSkills(preservedAgentIds)
|
||||
// Clear git dir resolution cache
|
||||
clearResolveGitDirCache()
|
||||
// Clear dynamic skills (loaded from skill directories)
|
||||
clearDynamicSkills()
|
||||
// Clear LSP diagnostic tracking state
|
||||
resetAllLSPDiagnosticState()
|
||||
// Clear tracked magic docs
|
||||
clearTrackedMagicDocs()
|
||||
// Clear session environment variables
|
||||
clearSessionEnvVars()
|
||||
// Clear WebFetch URL cache (up to 50MB of cached page content)
|
||||
void import('../../tools/WebFetchTool/utils.js').then(
|
||||
({ clearWebFetchCache }) => clearWebFetchCache(),
|
||||
)
|
||||
// Clear ToolSearch description cache (full tool prompts, ~500KB for 50 MCP tools)
|
||||
void import('../../tools/ToolSearchTool/ToolSearchTool.js').then(
|
||||
({ clearToolSearchDescriptionCache }) => clearToolSearchDescriptionCache(),
|
||||
)
|
||||
// Clear agent definitions cache (accumulates per-cwd via EnterWorktreeTool)
|
||||
void import('../../tools/AgentTool/loadAgentsDir.js').then(
|
||||
({ clearAgentDefinitionsCache }) => clearAgentDefinitionsCache(),
|
||||
)
|
||||
// Clear SkillTool prompt cache (accumulates per project root)
|
||||
void import('../../tools/SkillTool/prompt.js').then(({ clearPromptCache }) =>
|
||||
clearPromptCache(),
|
||||
)
|
||||
}
|
||||
7
src/commands/clear/clear.ts
Normal file
7
src/commands/clear/clear.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import type { LocalCommandCall } from '../../types/command.js'
|
||||
import { clearConversation } from './conversation.js'
|
||||
|
||||
export const call: LocalCommandCall = async (_, context) => {
|
||||
await clearConversation(context)
|
||||
return { type: 'text', value: '' }
|
||||
}
|
||||
251
src/commands/clear/conversation.ts
Normal file
251
src/commands/clear/conversation.ts
Normal file
@@ -0,0 +1,251 @@
|
||||
/**
|
||||
* Conversation clearing utility.
|
||||
* This module has heavier dependencies and should be lazy-loaded when possible.
|
||||
*/
|
||||
import { feature } from 'bun:bundle'
|
||||
import { randomUUID, type UUID } from 'crypto'
|
||||
import {
|
||||
getLastMainRequestId,
|
||||
getOriginalCwd,
|
||||
getSessionId,
|
||||
regenerateSessionId,
|
||||
} from '../../bootstrap/state.js'
|
||||
import {
|
||||
type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
||||
logEvent,
|
||||
} from '../../services/analytics/index.js'
|
||||
import type { AppState } from '../../state/AppState.js'
|
||||
import { isInProcessTeammateTask } from '../../tasks/InProcessTeammateTask/types.js'
|
||||
import {
|
||||
isLocalAgentTask,
|
||||
type LocalAgentTaskState,
|
||||
} from '../../tasks/LocalAgentTask/LocalAgentTask.js'
|
||||
import { isLocalShellTask } from '../../tasks/LocalShellTask/guards.js'
|
||||
import { asAgentId } from '../../types/ids.js'
|
||||
import type { Message } from '../../types/message.js'
|
||||
import { createEmptyAttributionState } from '../../utils/commitAttribution.js'
|
||||
import type { FileStateCache } from '../../utils/fileStateCache.js'
|
||||
import {
|
||||
executeSessionEndHooks,
|
||||
getSessionEndHookTimeoutMs,
|
||||
} from '../../utils/hooks.js'
|
||||
import { logError } from '../../utils/log.js'
|
||||
import { clearAllPlanSlugs } from '../../utils/plans.js'
|
||||
import { setCwd } from '../../utils/Shell.js'
|
||||
import { processSessionStartHooks } from '../../utils/sessionStart.js'
|
||||
import {
|
||||
clearSessionMetadata,
|
||||
getAgentTranscriptPath,
|
||||
resetSessionFilePointer,
|
||||
saveWorktreeState,
|
||||
} from '../../utils/sessionStorage.js'
|
||||
import {
|
||||
evictTaskOutput,
|
||||
initTaskOutputAsSymlink,
|
||||
} from '../../utils/task/diskOutput.js'
|
||||
import { getCurrentWorktreeSession } from '../../utils/worktree.js'
|
||||
import { clearSessionCaches } from './caches.js'
|
||||
|
||||
export async function clearConversation({
|
||||
setMessages,
|
||||
readFileState,
|
||||
discoveredSkillNames,
|
||||
loadedNestedMemoryPaths,
|
||||
getAppState,
|
||||
setAppState,
|
||||
setConversationId,
|
||||
}: {
|
||||
setMessages: (updater: (prev: Message[]) => Message[]) => void
|
||||
readFileState: FileStateCache
|
||||
discoveredSkillNames?: Set<string>
|
||||
loadedNestedMemoryPaths?: Set<string>
|
||||
getAppState?: () => AppState
|
||||
setAppState?: (f: (prev: AppState) => AppState) => void
|
||||
setConversationId?: (id: UUID) => void
|
||||
}): Promise<void> {
|
||||
// Execute SessionEnd hooks before clearing (bounded by
|
||||
// CLAUDE_CODE_SESSIONEND_HOOKS_TIMEOUT_MS, default 1.5s)
|
||||
const sessionEndTimeoutMs = getSessionEndHookTimeoutMs()
|
||||
await executeSessionEndHooks('clear', {
|
||||
getAppState,
|
||||
setAppState,
|
||||
signal: AbortSignal.timeout(sessionEndTimeoutMs),
|
||||
timeoutMs: sessionEndTimeoutMs,
|
||||
})
|
||||
|
||||
// Signal to inference that this conversation's cache can be evicted.
|
||||
const lastRequestId = getLastMainRequestId()
|
||||
if (lastRequestId) {
|
||||
logEvent('tengu_cache_eviction_hint', {
|
||||
scope:
|
||||
'conversation_clear' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
||||
last_request_id:
|
||||
lastRequestId as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
||||
})
|
||||
}
|
||||
|
||||
// Compute preserved tasks up front so their per-agent state survives the
|
||||
// cache wipe below. A task is preserved unless it explicitly has
|
||||
// isBackgrounded === false. Main-session tasks (Ctrl+B) are preserved —
|
||||
// they write to an isolated per-task transcript and run under an agent
|
||||
// context, so they're safe across session ID regeneration. See
|
||||
// LocalMainSessionTask.ts startBackgroundSession.
|
||||
const preservedAgentIds = new Set<string>()
|
||||
const preservedLocalAgents: LocalAgentTaskState[] = []
|
||||
const shouldKillTask = (task: AppState['tasks'][string]): boolean =>
|
||||
'isBackgrounded' in task && task.isBackgrounded === false
|
||||
if (getAppState) {
|
||||
for (const task of Object.values(getAppState().tasks)) {
|
||||
if (shouldKillTask(task)) continue
|
||||
if (isLocalAgentTask(task)) {
|
||||
preservedAgentIds.add(task.agentId)
|
||||
preservedLocalAgents.push(task)
|
||||
} else if (isInProcessTeammateTask(task)) {
|
||||
preservedAgentIds.add(task.identity.agentId)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
setMessages(() => [])
|
||||
|
||||
// Clear context-blocked flag so proactive ticks resume after /clear
|
||||
if (feature('PROACTIVE') || feature('KAIROS')) {
|
||||
/* eslint-disable @typescript-eslint/no-require-imports */
|
||||
const { setContextBlocked } = require('../../proactive/index.js')
|
||||
/* eslint-enable @typescript-eslint/no-require-imports */
|
||||
setContextBlocked(false)
|
||||
}
|
||||
|
||||
// Force logo re-render by updating conversationId
|
||||
if (setConversationId) {
|
||||
setConversationId(randomUUID())
|
||||
}
|
||||
|
||||
// Clear all session-related caches. Per-agent state for preserved background
|
||||
// tasks (invoked skills, pending permission callbacks, dump state, cache-break
|
||||
// tracking) is retained so those agents keep functioning.
|
||||
clearSessionCaches(preservedAgentIds)
|
||||
|
||||
setCwd(getOriginalCwd())
|
||||
readFileState.clear()
|
||||
discoveredSkillNames?.clear()
|
||||
loadedNestedMemoryPaths?.clear()
|
||||
|
||||
// Clean out necessary items from App State
|
||||
if (setAppState) {
|
||||
setAppState(prev => {
|
||||
// Partition tasks using the same predicate computed above:
|
||||
// kill+remove foreground tasks, preserve everything else.
|
||||
const nextTasks: AppState['tasks'] = {}
|
||||
for (const [taskId, task] of Object.entries(prev.tasks)) {
|
||||
if (!shouldKillTask(task)) {
|
||||
nextTasks[taskId] = task
|
||||
continue
|
||||
}
|
||||
// Foreground task: kill it and drop from state
|
||||
try {
|
||||
if (task.status === 'running') {
|
||||
if (isLocalShellTask(task)) {
|
||||
task.shellCommand?.kill()
|
||||
task.shellCommand?.cleanup()
|
||||
if (task.cleanupTimeoutId) {
|
||||
clearTimeout(task.cleanupTimeoutId)
|
||||
}
|
||||
}
|
||||
if ('abortController' in task) {
|
||||
task.abortController?.abort()
|
||||
}
|
||||
if ('unregisterCleanup' in task) {
|
||||
task.unregisterCleanup?.()
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
logError(error)
|
||||
}
|
||||
void evictTaskOutput(taskId)
|
||||
}
|
||||
|
||||
return {
|
||||
...prev,
|
||||
tasks: nextTasks,
|
||||
attribution: createEmptyAttributionState(),
|
||||
// Clear standalone agent context (name/color set by /rename, /color)
|
||||
// so the new session doesn't display the old session's identity badge
|
||||
standaloneAgentContext: undefined,
|
||||
fileHistory: {
|
||||
snapshots: [],
|
||||
trackedFiles: new Set(),
|
||||
snapshotSequence: 0,
|
||||
},
|
||||
// Reset MCP state to default to trigger re-initialization.
|
||||
// Preserve pluginReconnectKey so /clear doesn't cause a no-op
|
||||
// (it's only bumped by /reload-plugins).
|
||||
mcp: {
|
||||
clients: [],
|
||||
tools: [],
|
||||
commands: [],
|
||||
resources: {},
|
||||
pluginReconnectKey: prev.mcp.pluginReconnectKey,
|
||||
},
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// Clear plan slug cache so a new plan file is used after /clear
|
||||
clearAllPlanSlugs()
|
||||
|
||||
// Clear cached session metadata (title, tag, agent name/color)
|
||||
// so the new session doesn't inherit the previous session's identity
|
||||
clearSessionMetadata()
|
||||
|
||||
// Generate new session ID to provide fresh state
|
||||
// Set the old session as parent for analytics lineage tracking
|
||||
regenerateSessionId({ setCurrentAsParent: true })
|
||||
// Update the environment variable so subprocesses use the new session ID
|
||||
if (process.env.USER_TYPE === 'ant' && process.env.CLAUDE_CODE_SESSION_ID) {
|
||||
process.env.CLAUDE_CODE_SESSION_ID = getSessionId()
|
||||
}
|
||||
await resetSessionFilePointer()
|
||||
|
||||
// Preserved local_agent tasks had their TaskOutput symlink baked against the
|
||||
// old session ID at spawn time, but post-clear transcript writes land under
|
||||
// the new session directory (appendEntry re-reads getSessionId()). Re-point
|
||||
// the symlinks so TaskOutput reads the live file instead of a frozen pre-clear
|
||||
// snapshot. Only re-point running tasks — finished tasks will never write
|
||||
// again, so re-pointing would replace a valid symlink with a dangling one.
|
||||
// Main-session tasks use the same per-agent path (they write via
|
||||
// recordSidechainTranscript to getAgentTranscriptPath), so no special case.
|
||||
for (const task of preservedLocalAgents) {
|
||||
if (task.status !== 'running') continue
|
||||
void initTaskOutputAsSymlink(
|
||||
task.id,
|
||||
getAgentTranscriptPath(asAgentId(task.agentId)),
|
||||
)
|
||||
}
|
||||
|
||||
// Re-persist mode and worktree state after the clear so future --resume
|
||||
// knows what the new post-clear session was in. clearSessionMetadata
|
||||
// wiped both from the cache, but the process is still in the same mode
|
||||
// and (if applicable) the same worktree directory.
|
||||
if (feature('COORDINATOR_MODE')) {
|
||||
/* eslint-disable @typescript-eslint/no-require-imports */
|
||||
const { saveMode } = require('../../utils/sessionStorage.js')
|
||||
const {
|
||||
isCoordinatorMode,
|
||||
} = require('../../coordinator/coordinatorMode.js')
|
||||
/* eslint-enable @typescript-eslint/no-require-imports */
|
||||
saveMode(isCoordinatorMode() ? 'coordinator' : 'normal')
|
||||
}
|
||||
const worktreeSession = getCurrentWorktreeSession()
|
||||
if (worktreeSession) {
|
||||
saveWorktreeState(worktreeSession)
|
||||
}
|
||||
|
||||
// Execute SessionStart hooks after clearing
|
||||
const hookMessages = await processSessionStartHooks('clear')
|
||||
|
||||
// Update messages with hook results
|
||||
if (hookMessages.length > 0) {
|
||||
setMessages(() => hookMessages)
|
||||
}
|
||||
}
|
||||
19
src/commands/clear/index.ts
Normal file
19
src/commands/clear/index.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
/**
|
||||
* Clear command - minimal metadata only.
|
||||
* Implementation is lazy-loaded from clear.ts to reduce startup time.
|
||||
* Utility functions:
|
||||
* - clearSessionCaches: import from './clear/caches.js'
|
||||
* - clearConversation: import from './clear/conversation.js'
|
||||
*/
|
||||
import type { Command } from '../../commands.js'
|
||||
|
||||
const clear = {
|
||||
type: 'local',
|
||||
name: 'clear',
|
||||
description: 'Clear conversation history and free up context',
|
||||
aliases: ['reset', 'new'],
|
||||
supportsNonInteractive: false, // Should just create a new session
|
||||
load: () => import('./clear.js'),
|
||||
} satisfies Command
|
||||
|
||||
export default clear
|
||||
93
src/commands/color/color.ts
Normal file
93
src/commands/color/color.ts
Normal file
@@ -0,0 +1,93 @@
|
||||
import type { UUID } from 'crypto'
|
||||
import { getSessionId } from '../../bootstrap/state.js'
|
||||
import type { ToolUseContext } from '../../Tool.js'
|
||||
import {
|
||||
AGENT_COLORS,
|
||||
type AgentColorName,
|
||||
} from '../../tools/AgentTool/agentColorManager.js'
|
||||
import type {
|
||||
LocalJSXCommandContext,
|
||||
LocalJSXCommandOnDone,
|
||||
} from '../../types/command.js'
|
||||
import {
|
||||
getTranscriptPath,
|
||||
saveAgentColor,
|
||||
} from '../../utils/sessionStorage.js'
|
||||
import { isTeammate } from '../../utils/teammate.js'
|
||||
|
||||
const RESET_ALIASES = ['default', 'reset', 'none', 'gray', 'grey'] as const
|
||||
|
||||
export async function call(
|
||||
onDone: LocalJSXCommandOnDone,
|
||||
context: ToolUseContext & LocalJSXCommandContext,
|
||||
args: string,
|
||||
): Promise<null> {
|
||||
// Teammates cannot set their own color
|
||||
if (isTeammate()) {
|
||||
onDone(
|
||||
'Cannot set color: This session is a swarm teammate. Teammate colors are assigned by the team leader.',
|
||||
{ display: 'system' },
|
||||
)
|
||||
return null
|
||||
}
|
||||
|
||||
if (!args || args.trim() === '') {
|
||||
const colorList = AGENT_COLORS.join(', ')
|
||||
onDone(`Please provide a color. Available colors: ${colorList}, default`, {
|
||||
display: 'system',
|
||||
})
|
||||
return null
|
||||
}
|
||||
|
||||
const colorArg = args.trim().toLowerCase()
|
||||
|
||||
// Handle reset to default (gray)
|
||||
if (RESET_ALIASES.includes(colorArg as (typeof RESET_ALIASES)[number])) {
|
||||
const sessionId = getSessionId() as UUID
|
||||
const fullPath = getTranscriptPath()
|
||||
|
||||
// Use "default" sentinel (not empty string) so truthiness guards
|
||||
// in sessionStorage.ts persist the reset across session restarts
|
||||
await saveAgentColor(sessionId, 'default', fullPath)
|
||||
|
||||
context.setAppState(prev => ({
|
||||
...prev,
|
||||
standaloneAgentContext: {
|
||||
...prev.standaloneAgentContext,
|
||||
name: prev.standaloneAgentContext?.name ?? '',
|
||||
color: undefined,
|
||||
},
|
||||
}))
|
||||
|
||||
onDone('Session color reset to default', { display: 'system' })
|
||||
return null
|
||||
}
|
||||
|
||||
if (!AGENT_COLORS.includes(colorArg as AgentColorName)) {
|
||||
const colorList = AGENT_COLORS.join(', ')
|
||||
onDone(
|
||||
`Invalid color "${colorArg}". Available colors: ${colorList}, default`,
|
||||
{ display: 'system' },
|
||||
)
|
||||
return null
|
||||
}
|
||||
|
||||
const sessionId = getSessionId() as UUID
|
||||
const fullPath = getTranscriptPath()
|
||||
|
||||
// Save to transcript for persistence across sessions
|
||||
await saveAgentColor(sessionId, colorArg, fullPath)
|
||||
|
||||
// Update AppState for immediate effect
|
||||
context.setAppState(prev => ({
|
||||
...prev,
|
||||
standaloneAgentContext: {
|
||||
...prev.standaloneAgentContext,
|
||||
name: prev.standaloneAgentContext?.name ?? '',
|
||||
color: colorArg as AgentColorName,
|
||||
},
|
||||
}))
|
||||
|
||||
onDone(`Session color set to: ${colorArg}`, { display: 'system' })
|
||||
return null
|
||||
}
|
||||
16
src/commands/color/index.ts
Normal file
16
src/commands/color/index.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
/**
|
||||
* Color command - minimal metadata only.
|
||||
* Implementation is lazy-loaded from color.ts to reduce startup time.
|
||||
*/
|
||||
import type { Command } from '../../commands.js'
|
||||
|
||||
const color = {
|
||||
type: 'local-jsx',
|
||||
name: 'color',
|
||||
description: 'Set the prompt bar color for this session',
|
||||
immediate: true,
|
||||
argumentHint: '<color|default>',
|
||||
load: () => import('./color.js'),
|
||||
} satisfies Command
|
||||
|
||||
export default color
|
||||
158
src/commands/commit-push-pr.ts
Normal file
158
src/commands/commit-push-pr.ts
Normal file
@@ -0,0 +1,158 @@
|
||||
import type { Command } from '../commands.js'
|
||||
import {
|
||||
getAttributionTexts,
|
||||
getEnhancedPRAttribution,
|
||||
} from '../utils/attribution.js'
|
||||
import { getDefaultBranch } from '../utils/git.js'
|
||||
import { executeShellCommandsInPrompt } from '../utils/promptShellExecution.js'
|
||||
import { getUndercoverInstructions, isUndercover } from '../utils/undercover.js'
|
||||
|
||||
const ALLOWED_TOOLS = [
|
||||
'Bash(git checkout --branch:*)',
|
||||
'Bash(git checkout -b:*)',
|
||||
'Bash(git add:*)',
|
||||
'Bash(git status:*)',
|
||||
'Bash(git push:*)',
|
||||
'Bash(git commit:*)',
|
||||
'Bash(gh pr create:*)',
|
||||
'Bash(gh pr edit:*)',
|
||||
'Bash(gh pr view:*)',
|
||||
'Bash(gh pr merge:*)',
|
||||
'ToolSearch',
|
||||
'mcp__slack__send_message',
|
||||
'mcp__claude_ai_Slack__slack_send_message',
|
||||
]
|
||||
|
||||
function getPromptContent(
|
||||
defaultBranch: string,
|
||||
prAttribution?: string,
|
||||
): string {
|
||||
const { commit: commitAttribution, pr: defaultPrAttribution } =
|
||||
getAttributionTexts()
|
||||
// Use provided PR attribution or fall back to default
|
||||
const effectivePrAttribution = prAttribution ?? defaultPrAttribution
|
||||
const safeUser = process.env.SAFEUSER || ''
|
||||
const username = process.env.USER || ''
|
||||
|
||||
let prefix = ''
|
||||
let reviewerArg = ' and `--reviewer anthropics/claude-code`'
|
||||
let addReviewerArg = ' (and add `--add-reviewer anthropics/claude-code`)'
|
||||
let changelogSection = `
|
||||
|
||||
## Changelog
|
||||
<!-- CHANGELOG:START -->
|
||||
[If this PR contains user-facing changes, add a changelog entry here. Otherwise, remove this section.]
|
||||
<!-- CHANGELOG:END -->`
|
||||
let slackStep = `
|
||||
|
||||
5. After creating/updating the PR, check if the user's CLAUDE.md mentions posting to Slack channels. If it does, use ToolSearch to search for "slack send message" tools. If ToolSearch finds a Slack tool, ask the user if they'd like you to post the PR URL to the relevant Slack channel. Only post if the user confirms. If ToolSearch returns no results or errors, skip this step silently—do not mention the failure, do not attempt workarounds, and do not try alternative approaches.`
|
||||
if (process.env.USER_TYPE === 'ant' && isUndercover()) {
|
||||
prefix = getUndercoverInstructions() + '\n'
|
||||
reviewerArg = ''
|
||||
addReviewerArg = ''
|
||||
changelogSection = ''
|
||||
slackStep = ''
|
||||
}
|
||||
|
||||
return `${prefix}## Context
|
||||
|
||||
- \`SAFEUSER\`: ${safeUser}
|
||||
- \`whoami\`: ${username}
|
||||
- \`git status\`: !\`git status\`
|
||||
- \`git diff HEAD\`: !\`git diff HEAD\`
|
||||
- \`git branch --show-current\`: !\`git branch --show-current\`
|
||||
- \`git diff ${defaultBranch}...HEAD\`: !\`git diff ${defaultBranch}...HEAD\`
|
||||
- \`gh pr view --json number 2>/dev/null || true\`: !\`gh pr view --json number 2>/dev/null || true\`
|
||||
|
||||
## Git Safety Protocol
|
||||
|
||||
- NEVER update the git config
|
||||
- NEVER run destructive/irreversible git commands (like push --force, hard reset, etc) unless the user explicitly requests them
|
||||
- NEVER skip hooks (--no-verify, --no-gpg-sign, etc) unless the user explicitly requests it
|
||||
- NEVER run force push to main/master, warn the user if they request it
|
||||
- Do not commit files that likely contain secrets (.env, credentials.json, etc)
|
||||
- Never use git commands with the -i flag (like git rebase -i or git add -i) since they require interactive input which is not supported
|
||||
|
||||
## Your task
|
||||
|
||||
Analyze all changes that will be included in the pull request, making sure to look at all relevant commits (NOT just the latest commit, but ALL commits that will be included in the pull request from the git diff ${defaultBranch}...HEAD output above).
|
||||
|
||||
Based on the above changes:
|
||||
1. Create a new branch if on ${defaultBranch} (use SAFEUSER from context above for the branch name prefix, falling back to whoami if SAFEUSER is empty, e.g., \`username/feature-name\`)
|
||||
2. Create a single commit with an appropriate message using heredoc syntax${commitAttribution ? `, ending with the attribution text shown in the example below` : ''}:
|
||||
\`\`\`
|
||||
git commit -m "$(cat <<'EOF'
|
||||
Commit message here.${commitAttribution ? `\n\n${commitAttribution}` : ''}
|
||||
EOF
|
||||
)"
|
||||
\`\`\`
|
||||
3. Push the branch to origin
|
||||
4. If a PR already exists for this branch (check the gh pr view output above), update the PR title and body using \`gh pr edit\` to reflect the current diff${addReviewerArg}. Otherwise, create a pull request using \`gh pr create\` with heredoc syntax for the body${reviewerArg}.
|
||||
- IMPORTANT: Keep PR titles short (under 70 characters). Use the body for details.
|
||||
\`\`\`
|
||||
gh pr create --title "Short, descriptive title" --body "$(cat <<'EOF'
|
||||
## Summary
|
||||
<1-3 bullet points>
|
||||
|
||||
## Test plan
|
||||
[Bulleted markdown checklist of TODOs for testing the pull request...]${changelogSection}${effectivePrAttribution ? `\n\n${effectivePrAttribution}` : ''}
|
||||
EOF
|
||||
)"
|
||||
\`\`\`
|
||||
|
||||
You have the capability to call multiple tools in a single response. You MUST do all of the above in a single message.${slackStep}
|
||||
|
||||
Return the PR URL when you're done, so the user can see it.`
|
||||
}
|
||||
|
||||
const command = {
|
||||
type: 'prompt',
|
||||
name: 'commit-push-pr',
|
||||
description: 'Commit, push, and open a PR',
|
||||
allowedTools: ALLOWED_TOOLS,
|
||||
get contentLength() {
|
||||
// Use 'main' as estimate for content length calculation
|
||||
return getPromptContent('main').length
|
||||
},
|
||||
progressMessage: 'creating commit and PR',
|
||||
source: 'builtin',
|
||||
async getPromptForCommand(args, context) {
|
||||
// Get default branch and enhanced PR attribution
|
||||
const [defaultBranch, prAttribution] = await Promise.all([
|
||||
getDefaultBranch(),
|
||||
getEnhancedPRAttribution(context.getAppState),
|
||||
])
|
||||
let promptContent = getPromptContent(defaultBranch, prAttribution)
|
||||
|
||||
// Append user instructions if args provided
|
||||
const trimmedArgs = args?.trim()
|
||||
if (trimmedArgs) {
|
||||
promptContent += `\n\n## Additional instructions from user\n\n${trimmedArgs}`
|
||||
}
|
||||
|
||||
const finalContent = await executeShellCommandsInPrompt(
|
||||
promptContent,
|
||||
{
|
||||
...context,
|
||||
getAppState() {
|
||||
const appState = context.getAppState()
|
||||
return {
|
||||
...appState,
|
||||
toolPermissionContext: {
|
||||
...appState.toolPermissionContext,
|
||||
alwaysAllowRules: {
|
||||
...appState.toolPermissionContext.alwaysAllowRules,
|
||||
command: ALLOWED_TOOLS,
|
||||
},
|
||||
},
|
||||
}
|
||||
},
|
||||
},
|
||||
'/commit-push-pr',
|
||||
)
|
||||
|
||||
return [{ type: 'text', text: finalContent }]
|
||||
},
|
||||
} satisfies Command
|
||||
|
||||
export default command
|
||||
92
src/commands/commit.ts
Normal file
92
src/commands/commit.ts
Normal file
@@ -0,0 +1,92 @@
|
||||
import type { Command } from '../commands.js'
|
||||
import { getAttributionTexts } from '../utils/attribution.js'
|
||||
import { executeShellCommandsInPrompt } from '../utils/promptShellExecution.js'
|
||||
import { getUndercoverInstructions, isUndercover } from '../utils/undercover.js'
|
||||
|
||||
const ALLOWED_TOOLS = [
|
||||
'Bash(git add:*)',
|
||||
'Bash(git status:*)',
|
||||
'Bash(git commit:*)',
|
||||
]
|
||||
|
||||
function getPromptContent(): string {
|
||||
const { commit: commitAttribution } = getAttributionTexts()
|
||||
|
||||
let prefix = ''
|
||||
if (process.env.USER_TYPE === 'ant' && isUndercover()) {
|
||||
prefix = getUndercoverInstructions() + '\n'
|
||||
}
|
||||
|
||||
return `${prefix}## Context
|
||||
|
||||
- Current git status: !\`git status\`
|
||||
- Current git diff (staged and unstaged changes): !\`git diff HEAD\`
|
||||
- Current branch: !\`git branch --show-current\`
|
||||
- Recent commits: !\`git log --oneline -10\`
|
||||
|
||||
## Git Safety Protocol
|
||||
|
||||
- NEVER update the git config
|
||||
- NEVER skip hooks (--no-verify, --no-gpg-sign, etc) unless the user explicitly requests it
|
||||
- CRITICAL: ALWAYS create NEW commits. NEVER use git commit --amend, unless the user explicitly requests it
|
||||
- Do not commit files that likely contain secrets (.env, credentials.json, etc). Warn the user if they specifically request to commit those files
|
||||
- If there are no changes to commit (i.e., no untracked files and no modifications), do not create an empty commit
|
||||
- Never use git commands with the -i flag (like git rebase -i or git add -i) since they require interactive input which is not supported
|
||||
|
||||
## Your task
|
||||
|
||||
Based on the above changes, create a single git commit:
|
||||
|
||||
1. Analyze all staged changes and draft a commit message:
|
||||
- Look at the recent commits above to follow this repository's commit message style
|
||||
- Summarize the nature of the changes (new feature, enhancement, bug fix, refactoring, test, docs, etc.)
|
||||
- Ensure the message accurately reflects the changes and their purpose (i.e. "add" means a wholly new feature, "update" means an enhancement to an existing feature, "fix" means a bug fix, etc.)
|
||||
- Draft a concise (1-2 sentences) commit message that focuses on the "why" rather than the "what"
|
||||
|
||||
2. Stage relevant files and create the commit using HEREDOC syntax:
|
||||
\`\`\`
|
||||
git commit -m "$(cat <<'EOF'
|
||||
Commit message here.${commitAttribution ? `\n\n${commitAttribution}` : ''}
|
||||
EOF
|
||||
)"
|
||||
\`\`\`
|
||||
|
||||
You have the capability to call multiple tools in a single response. Stage and create the commit using a single message. Do not use any other tools or do anything else. Do not send any other text or messages besides these tool calls.`
|
||||
}
|
||||
|
||||
const command = {
|
||||
type: 'prompt',
|
||||
name: 'commit',
|
||||
description: 'Create a git commit',
|
||||
allowedTools: ALLOWED_TOOLS,
|
||||
contentLength: 0, // Dynamic content
|
||||
progressMessage: 'creating commit',
|
||||
source: 'builtin',
|
||||
async getPromptForCommand(_args, context) {
|
||||
const promptContent = getPromptContent()
|
||||
const finalContent = await executeShellCommandsInPrompt(
|
||||
promptContent,
|
||||
{
|
||||
...context,
|
||||
getAppState() {
|
||||
const appState = context.getAppState()
|
||||
return {
|
||||
...appState,
|
||||
toolPermissionContext: {
|
||||
...appState.toolPermissionContext,
|
||||
alwaysAllowRules: {
|
||||
...appState.toolPermissionContext.alwaysAllowRules,
|
||||
command: ALLOWED_TOOLS,
|
||||
},
|
||||
},
|
||||
}
|
||||
},
|
||||
},
|
||||
'/commit',
|
||||
)
|
||||
|
||||
return [{ type: 'text', text: finalContent }]
|
||||
},
|
||||
} satisfies Command
|
||||
|
||||
export default command
|
||||
287
src/commands/compact/compact.ts
Normal file
287
src/commands/compact/compact.ts
Normal file
@@ -0,0 +1,287 @@
|
||||
import { feature } from 'bun:bundle'
|
||||
import chalk from 'chalk'
|
||||
import { markPostCompaction } from 'src/bootstrap/state.js'
|
||||
import { getSystemPrompt } from '../../constants/prompts.js'
|
||||
import { getSystemContext, getUserContext } from '../../context.js'
|
||||
import { getShortcutDisplay } from '../../keybindings/shortcutFormat.js'
|
||||
import { notifyCompaction } from '../../services/api/promptCacheBreakDetection.js'
|
||||
import {
|
||||
type CompactionResult,
|
||||
compactConversation,
|
||||
ERROR_MESSAGE_INCOMPLETE_RESPONSE,
|
||||
ERROR_MESSAGE_NOT_ENOUGH_MESSAGES,
|
||||
ERROR_MESSAGE_USER_ABORT,
|
||||
mergeHookInstructions,
|
||||
} from '../../services/compact/compact.js'
|
||||
import { suppressCompactWarning } from '../../services/compact/compactWarningState.js'
|
||||
import { microcompactMessages } from '../../services/compact/microCompact.js'
|
||||
import { runPostCompactCleanup } from '../../services/compact/postCompactCleanup.js'
|
||||
import { trySessionMemoryCompaction } from '../../services/compact/sessionMemoryCompact.js'
|
||||
import { setLastSummarizedMessageId } from '../../services/SessionMemory/sessionMemoryUtils.js'
|
||||
import type { ToolUseContext } from '../../Tool.js'
|
||||
import type { LocalCommandCall } from '../../types/command.js'
|
||||
import type { Message } from '../../types/message.js'
|
||||
import { hasExactErrorMessage } from '../../utils/errors.js'
|
||||
import { executePreCompactHooks } from '../../utils/hooks.js'
|
||||
import { logError } from '../../utils/log.js'
|
||||
import { getMessagesAfterCompactBoundary } from '../../utils/messages.js'
|
||||
import { getUpgradeMessage } from '../../utils/model/contextWindowUpgradeCheck.js'
|
||||
import {
|
||||
buildEffectiveSystemPrompt,
|
||||
type SystemPrompt,
|
||||
} from '../../utils/systemPrompt.js'
|
||||
|
||||
/* eslint-disable @typescript-eslint/no-require-imports */
|
||||
const reactiveCompact = feature('REACTIVE_COMPACT')
|
||||
? (require('../../services/compact/reactiveCompact.js') as typeof import('../../services/compact/reactiveCompact.js'))
|
||||
: null
|
||||
/* eslint-enable @typescript-eslint/no-require-imports */
|
||||
|
||||
export const call: LocalCommandCall = async (args, context) => {
|
||||
const { abortController } = context
|
||||
let { messages } = context
|
||||
|
||||
// REPL keeps snipped messages for UI scrollback — project so the compact
|
||||
// model doesn't summarize content that was intentionally removed.
|
||||
messages = getMessagesAfterCompactBoundary(messages)
|
||||
|
||||
if (messages.length === 0) {
|
||||
throw new Error('No messages to compact')
|
||||
}
|
||||
|
||||
const customInstructions = args.trim()
|
||||
|
||||
try {
|
||||
// Try session memory compaction first if no custom instructions
|
||||
// (session memory compaction doesn't support custom instructions)
|
||||
if (!customInstructions) {
|
||||
const sessionMemoryResult = await trySessionMemoryCompaction(
|
||||
messages,
|
||||
context.agentId,
|
||||
)
|
||||
if (sessionMemoryResult) {
|
||||
getUserContext.cache.clear?.()
|
||||
runPostCompactCleanup()
|
||||
// Reset cache read baseline so the post-compact drop isn't flagged
|
||||
// as a break. compactConversation does this internally; SM-compact doesn't.
|
||||
if (feature('PROMPT_CACHE_BREAK_DETECTION')) {
|
||||
notifyCompaction(
|
||||
context.options.querySource ?? 'compact',
|
||||
context.agentId,
|
||||
)
|
||||
}
|
||||
markPostCompaction()
|
||||
// Suppress warning immediately after successful compaction
|
||||
suppressCompactWarning()
|
||||
|
||||
return {
|
||||
type: 'compact',
|
||||
compactionResult: sessionMemoryResult,
|
||||
displayText: buildDisplayText(context),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Reactive-only mode: route /compact through the reactive path.
|
||||
// Checked after session-memory (that path is cheap and orthogonal).
|
||||
if (reactiveCompact?.isReactiveOnlyMode()) {
|
||||
return await compactViaReactive(
|
||||
messages,
|
||||
context,
|
||||
customInstructions,
|
||||
reactiveCompact,
|
||||
)
|
||||
}
|
||||
|
||||
// Fall back to traditional compaction
|
||||
// Run microcompact first to reduce tokens before summarization
|
||||
const microcompactResult = await microcompactMessages(messages, context)
|
||||
const messagesForCompact = microcompactResult.messages
|
||||
|
||||
const result = await compactConversation(
|
||||
messagesForCompact,
|
||||
context,
|
||||
await getCacheSharingParams(context, messagesForCompact),
|
||||
false,
|
||||
customInstructions,
|
||||
false,
|
||||
)
|
||||
|
||||
// Reset lastSummarizedMessageId since legacy compaction replaces all messages
|
||||
// and the old message UUID will no longer exist in the new messages array
|
||||
setLastSummarizedMessageId(undefined)
|
||||
|
||||
// Suppress the "Context left until auto-compact" warning after successful compaction
|
||||
suppressCompactWarning()
|
||||
|
||||
getUserContext.cache.clear?.()
|
||||
runPostCompactCleanup()
|
||||
|
||||
return {
|
||||
type: 'compact',
|
||||
compactionResult: result,
|
||||
displayText: buildDisplayText(context, result.userDisplayMessage),
|
||||
}
|
||||
} catch (error) {
|
||||
if (abortController.signal.aborted) {
|
||||
throw new Error('Compaction canceled.')
|
||||
} else if (hasExactErrorMessage(error, ERROR_MESSAGE_NOT_ENOUGH_MESSAGES)) {
|
||||
throw new Error(ERROR_MESSAGE_NOT_ENOUGH_MESSAGES)
|
||||
} else if (hasExactErrorMessage(error, ERROR_MESSAGE_INCOMPLETE_RESPONSE)) {
|
||||
throw new Error(ERROR_MESSAGE_INCOMPLETE_RESPONSE)
|
||||
} else {
|
||||
logError(error)
|
||||
throw new Error(`Error during compaction: ${error}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function compactViaReactive(
|
||||
messages: Message[],
|
||||
context: ToolUseContext,
|
||||
customInstructions: string,
|
||||
reactive: NonNullable<typeof reactiveCompact>,
|
||||
): Promise<{
|
||||
type: 'compact'
|
||||
compactionResult: CompactionResult
|
||||
displayText: string
|
||||
}> {
|
||||
context.onCompactProgress?.({
|
||||
type: 'hooks_start',
|
||||
hookType: 'pre_compact',
|
||||
})
|
||||
context.setSDKStatus?.('compacting')
|
||||
|
||||
try {
|
||||
// Hooks and cache-param build are independent — run concurrently.
|
||||
// getCacheSharingParams walks all tools to build the system prompt;
|
||||
// pre-compact hooks spawn subprocesses. Neither depends on the other.
|
||||
const [hookResult, cacheSafeParams] = await Promise.all([
|
||||
executePreCompactHooks(
|
||||
{ trigger: 'manual', customInstructions: customInstructions || null },
|
||||
context.abortController.signal,
|
||||
),
|
||||
getCacheSharingParams(context, messages),
|
||||
])
|
||||
const mergedInstructions = mergeHookInstructions(
|
||||
customInstructions,
|
||||
hookResult.newCustomInstructions,
|
||||
)
|
||||
|
||||
context.setStreamMode?.('requesting')
|
||||
context.setResponseLength?.(() => 0)
|
||||
context.onCompactProgress?.({ type: 'compact_start' })
|
||||
|
||||
const outcome = await reactive.reactiveCompactOnPromptTooLong(
|
||||
messages,
|
||||
cacheSafeParams,
|
||||
{ customInstructions: mergedInstructions, trigger: 'manual' },
|
||||
)
|
||||
|
||||
if (!outcome.ok) {
|
||||
// The outer catch in `call` translates these: aborted → "Compaction
|
||||
// canceled." (via abortController.signal.aborted check), NOT_ENOUGH →
|
||||
// re-thrown as-is, everything else → "Error during compaction: …".
|
||||
switch (outcome.reason) {
|
||||
case 'too_few_groups':
|
||||
throw new Error(ERROR_MESSAGE_NOT_ENOUGH_MESSAGES)
|
||||
case 'aborted':
|
||||
throw new Error(ERROR_MESSAGE_USER_ABORT)
|
||||
case 'exhausted':
|
||||
case 'error':
|
||||
case 'media_unstrippable':
|
||||
throw new Error(ERROR_MESSAGE_INCOMPLETE_RESPONSE)
|
||||
}
|
||||
}
|
||||
|
||||
// Mirrors the post-success cleanup in tryReactiveCompact, minus
|
||||
// resetMicrocompactState — processSlashCommand calls that for all
|
||||
// type:'compact' results.
|
||||
setLastSummarizedMessageId(undefined)
|
||||
runPostCompactCleanup()
|
||||
suppressCompactWarning()
|
||||
getUserContext.cache.clear?.()
|
||||
|
||||
// reactiveCompactOnPromptTooLong runs PostCompact hooks but not PreCompact
|
||||
// — both callers (here and tryReactiveCompact) run PreCompact outside so
|
||||
// they can merge its userDisplayMessage with PostCompact's here. This
|
||||
// caller additionally runs it concurrently with getCacheSharingParams.
|
||||
const combinedMessage =
|
||||
[hookResult.userDisplayMessage, outcome.result.userDisplayMessage]
|
||||
.filter(Boolean)
|
||||
.join('\n') || undefined
|
||||
|
||||
return {
|
||||
type: 'compact',
|
||||
compactionResult: {
|
||||
...outcome.result,
|
||||
userDisplayMessage: combinedMessage,
|
||||
},
|
||||
displayText: buildDisplayText(context, combinedMessage),
|
||||
}
|
||||
} finally {
|
||||
context.setStreamMode?.('requesting')
|
||||
context.setResponseLength?.(() => 0)
|
||||
context.onCompactProgress?.({ type: 'compact_end' })
|
||||
context.setSDKStatus?.(null)
|
||||
}
|
||||
}
|
||||
|
||||
function buildDisplayText(
|
||||
context: ToolUseContext,
|
||||
userDisplayMessage?: string,
|
||||
): string {
|
||||
const upgradeMessage = getUpgradeMessage('tip')
|
||||
const expandShortcut = getShortcutDisplay(
|
||||
'app:toggleTranscript',
|
||||
'Global',
|
||||
'ctrl+o',
|
||||
)
|
||||
const dimmed = [
|
||||
...(context.options.verbose
|
||||
? []
|
||||
: [`(${expandShortcut} to see full summary)`]),
|
||||
...(userDisplayMessage ? [userDisplayMessage] : []),
|
||||
...(upgradeMessage ? [upgradeMessage] : []),
|
||||
]
|
||||
return chalk.dim('Compacted ' + dimmed.join('\n'))
|
||||
}
|
||||
|
||||
async function getCacheSharingParams(
|
||||
context: ToolUseContext,
|
||||
forkContextMessages: Message[],
|
||||
): Promise<{
|
||||
systemPrompt: SystemPrompt
|
||||
userContext: { [k: string]: string }
|
||||
systemContext: { [k: string]: string }
|
||||
toolUseContext: ToolUseContext
|
||||
forkContextMessages: Message[]
|
||||
}> {
|
||||
const appState = context.getAppState()
|
||||
const defaultSysPrompt = await getSystemPrompt(
|
||||
context.options.tools,
|
||||
context.options.mainLoopModel,
|
||||
Array.from(
|
||||
appState.toolPermissionContext.additionalWorkingDirectories.keys(),
|
||||
),
|
||||
context.options.mcpClients,
|
||||
)
|
||||
const systemPrompt = buildEffectiveSystemPrompt({
|
||||
mainThreadAgentDefinition: undefined,
|
||||
toolUseContext: context,
|
||||
customSystemPrompt: context.options.customSystemPrompt,
|
||||
defaultSystemPrompt: defaultSysPrompt,
|
||||
appendSystemPrompt: context.options.appendSystemPrompt,
|
||||
})
|
||||
const [userContext, systemContext] = await Promise.all([
|
||||
getUserContext(),
|
||||
getSystemContext(),
|
||||
])
|
||||
return {
|
||||
systemPrompt,
|
||||
userContext,
|
||||
systemContext,
|
||||
toolUseContext: context,
|
||||
forkContextMessages,
|
||||
}
|
||||
}
|
||||
15
src/commands/compact/index.ts
Normal file
15
src/commands/compact/index.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import type { Command } from '../../commands.js'
|
||||
import { isEnvTruthy } from '../../utils/envUtils.js'
|
||||
|
||||
const compact = {
|
||||
type: 'local',
|
||||
name: 'compact',
|
||||
description:
|
||||
'Clear conversation history but keep a summary in context. Optional: /compact [instructions for summarization]',
|
||||
isEnabled: () => !isEnvTruthy(process.env.DISABLE_COMPACT),
|
||||
supportsNonInteractive: true,
|
||||
argumentHint: '<optional custom summarization instructions>',
|
||||
load: () => import('./compact.js'),
|
||||
} satisfies Command
|
||||
|
||||
export default compact
|
||||
7
src/commands/config/config.tsx
Normal file
7
src/commands/config/config.tsx
Normal file
@@ -0,0 +1,7 @@
|
||||
import * as React from 'react';
|
||||
import { Settings } from '../../components/Settings/Settings.js';
|
||||
import type { LocalJSXCommandCall } from '../../types/command.js';
|
||||
export const call: LocalJSXCommandCall = async (onDone, context) => {
|
||||
return <Settings onClose={onDone} context={context} defaultTab="Config" />;
|
||||
};
|
||||
//# sourceMappingURL=data:application/json;charset=utf-8;base64,eyJ2ZXJzaW9uIjozLCJuYW1lcyI6WyJSZWFjdCIsIlNldHRpbmdzIiwiTG9jYWxKU1hDb21tYW5kQ2FsbCIsImNhbGwiLCJvbkRvbmUiLCJjb250ZXh0Il0sInNvdXJjZXMiOlsiY29uZmlnLnRzeCJdLCJzb3VyY2VzQ29udGVudCI6WyJpbXBvcnQgKiBhcyBSZWFjdCBmcm9tICdyZWFjdCdcbmltcG9ydCB7IFNldHRpbmdzIH0gZnJvbSAnLi4vLi4vY29tcG9uZW50cy9TZXR0aW5ncy9TZXR0aW5ncy5qcydcbmltcG9ydCB0eXBlIHsgTG9jYWxKU1hDb21tYW5kQ2FsbCB9IGZyb20gJy4uLy4uL3R5cGVzL2NvbW1hbmQuanMnXG5cbmV4cG9ydCBjb25zdCBjYWxsOiBMb2NhbEpTWENvbW1hbmRDYWxsID0gYXN5bmMgKG9uRG9uZSwgY29udGV4dCkgPT4ge1xuICByZXR1cm4gPFNldHRpbmdzIG9uQ2xvc2U9e29uRG9uZX0gY29udGV4dD17Y29udGV4dH0gZGVmYXVsdFRhYj1cIkNvbmZpZ1wiIC8+XG59XG4iXSwibWFwcGluZ3MiOiJBQUFBLE9BQU8sS0FBS0EsS0FBSyxNQUFNLE9BQU87QUFDOUIsU0FBU0MsUUFBUSxRQUFRLHVDQUF1QztBQUNoRSxjQUFjQyxtQkFBbUIsUUFBUSx3QkFBd0I7QUFFakUsT0FBTyxNQUFNQyxJQUFJLEVBQUVELG1CQUFtQixHQUFHLE1BQUFDLENBQU9DLE1BQU0sRUFBRUMsT0FBTyxLQUFLO0VBQ2xFLE9BQU8sQ0FBQyxRQUFRLENBQUMsT0FBTyxDQUFDLENBQUNELE1BQU0sQ0FBQyxDQUFDLE9BQU8sQ0FBQyxDQUFDQyxPQUFPLENBQUMsQ0FBQyxVQUFVLENBQUMsUUFBUSxHQUFHO0FBQzVFLENBQUMiLCJpZ25vcmVMaXN0IjpbXX0=
|
||||
11
src/commands/config/index.ts
Normal file
11
src/commands/config/index.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import type { Command } from '../../commands.js'
|
||||
|
||||
const config = {
|
||||
aliases: ['settings'],
|
||||
type: 'local-jsx',
|
||||
name: 'config',
|
||||
description: 'Open config panel',
|
||||
load: () => import('./config.js'),
|
||||
} satisfies Command
|
||||
|
||||
export default config
|
||||
325
src/commands/context/context-noninteractive.ts
Normal file
325
src/commands/context/context-noninteractive.ts
Normal file
@@ -0,0 +1,325 @@
|
||||
import { feature } from 'bun:bundle'
|
||||
import { microcompactMessages } from '../../services/compact/microCompact.js'
|
||||
import type { AppState } from '../../state/AppStateStore.js'
|
||||
import type { Tools, ToolUseContext } from '../../Tool.js'
|
||||
import type { AgentDefinitionsResult } from '../../tools/AgentTool/loadAgentsDir.js'
|
||||
import type { Message } from '../../types/message.js'
|
||||
import {
|
||||
analyzeContextUsage,
|
||||
type ContextData,
|
||||
} from '../../utils/analyzeContext.js'
|
||||
import { formatTokens } from '../../utils/format.js'
|
||||
import { getMessagesAfterCompactBoundary } from '../../utils/messages.js'
|
||||
import { getSourceDisplayName } from '../../utils/settings/constants.js'
|
||||
import { plural } from '../../utils/stringUtils.js'
|
||||
|
||||
/**
|
||||
* Shared data-collection path for `/context` (slash command) and the SDK
|
||||
* `get_context_usage` control request. Mirrors query.ts's pre-API transforms
|
||||
* (compact boundary, projectView, microcompact) so the token count reflects
|
||||
* what the model actually sees.
|
||||
*/
|
||||
type CollectContextDataInput = {
|
||||
messages: Message[]
|
||||
getAppState: () => AppState
|
||||
options: {
|
||||
mainLoopModel: string
|
||||
tools: Tools
|
||||
agentDefinitions: AgentDefinitionsResult
|
||||
customSystemPrompt?: string
|
||||
appendSystemPrompt?: string
|
||||
}
|
||||
}
|
||||
|
||||
export async function collectContextData(
|
||||
context: CollectContextDataInput,
|
||||
): Promise<ContextData> {
|
||||
const {
|
||||
messages,
|
||||
getAppState,
|
||||
options: {
|
||||
mainLoopModel,
|
||||
tools,
|
||||
agentDefinitions,
|
||||
customSystemPrompt,
|
||||
appendSystemPrompt,
|
||||
},
|
||||
} = context
|
||||
|
||||
let apiView = getMessagesAfterCompactBoundary(messages)
|
||||
if (feature('CONTEXT_COLLAPSE')) {
|
||||
/* eslint-disable @typescript-eslint/no-require-imports */
|
||||
const { projectView } =
|
||||
require('../../services/contextCollapse/operations.js') as typeof import('../../services/contextCollapse/operations.js')
|
||||
/* eslint-enable @typescript-eslint/no-require-imports */
|
||||
apiView = projectView(apiView)
|
||||
}
|
||||
|
||||
const { messages: compactedMessages } = await microcompactMessages(apiView)
|
||||
const appState = getAppState()
|
||||
|
||||
return analyzeContextUsage(
|
||||
compactedMessages,
|
||||
mainLoopModel,
|
||||
async () => appState.toolPermissionContext,
|
||||
tools,
|
||||
agentDefinitions,
|
||||
undefined, // terminalWidth
|
||||
// analyzeContextUsage only reads options.{customSystemPrompt,appendSystemPrompt}
|
||||
// but its signature declares the full Pick<ToolUseContext, 'options'>.
|
||||
{ options: { customSystemPrompt, appendSystemPrompt } } as Pick<
|
||||
ToolUseContext,
|
||||
'options'
|
||||
>,
|
||||
undefined, // mainThreadAgentDefinition
|
||||
apiView, // original messages for API usage extraction
|
||||
)
|
||||
}
|
||||
|
||||
export async function call(
|
||||
_args: string,
|
||||
context: ToolUseContext,
|
||||
): Promise<{ type: 'text'; value: string }> {
|
||||
const data = await collectContextData(context)
|
||||
return {
|
||||
type: 'text' as const,
|
||||
value: formatContextAsMarkdownTable(data),
|
||||
}
|
||||
}
|
||||
|
||||
function formatContextAsMarkdownTable(data: ContextData): string {
|
||||
const {
|
||||
categories,
|
||||
totalTokens,
|
||||
rawMaxTokens,
|
||||
percentage,
|
||||
model,
|
||||
memoryFiles,
|
||||
mcpTools,
|
||||
agents,
|
||||
skills,
|
||||
messageBreakdown,
|
||||
systemTools,
|
||||
systemPromptSections,
|
||||
} = data
|
||||
|
||||
let output = `## Context Usage\n\n`
|
||||
output += `**Model:** ${model} \n`
|
||||
output += `**Tokens:** ${formatTokens(totalTokens)} / ${formatTokens(rawMaxTokens)} (${percentage}%)\n`
|
||||
|
||||
// Context-collapse status. Always show when the runtime gate is on —
|
||||
// the user needs to know which strategy is managing their context
|
||||
// even before anything has fired.
|
||||
if (feature('CONTEXT_COLLAPSE')) {
|
||||
/* eslint-disable @typescript-eslint/no-require-imports */
|
||||
const { getStats, isContextCollapseEnabled } =
|
||||
require('../../services/contextCollapse/index.js') as typeof import('../../services/contextCollapse/index.js')
|
||||
/* eslint-enable @typescript-eslint/no-require-imports */
|
||||
if (isContextCollapseEnabled()) {
|
||||
const s = getStats()
|
||||
const { health: h } = s
|
||||
|
||||
const parts = []
|
||||
if (s.collapsedSpans > 0) {
|
||||
parts.push(
|
||||
`${s.collapsedSpans} ${plural(s.collapsedSpans, 'span')} summarized (${s.collapsedMessages} messages)`,
|
||||
)
|
||||
}
|
||||
if (s.stagedSpans > 0) parts.push(`${s.stagedSpans} staged`)
|
||||
const summary =
|
||||
parts.length > 0
|
||||
? parts.join(', ')
|
||||
: h.totalSpawns > 0
|
||||
? `${h.totalSpawns} ${plural(h.totalSpawns, 'spawn')}, nothing staged yet`
|
||||
: 'waiting for first trigger'
|
||||
output += `**Context strategy:** collapse (${summary})\n`
|
||||
|
||||
if (h.totalErrors > 0) {
|
||||
output += `**Collapse errors:** ${h.totalErrors}/${h.totalSpawns} spawns failed`
|
||||
if (h.lastError) {
|
||||
output += ` (last: ${h.lastError.slice(0, 80)})`
|
||||
}
|
||||
output += '\n'
|
||||
} else if (h.emptySpawnWarningEmitted) {
|
||||
output += `**Collapse idle:** ${h.totalEmptySpawns} consecutive empty runs\n`
|
||||
}
|
||||
}
|
||||
}
|
||||
output += '\n'
|
||||
|
||||
// Main categories table
|
||||
const visibleCategories = categories.filter(
|
||||
cat =>
|
||||
cat.tokens > 0 &&
|
||||
cat.name !== 'Free space' &&
|
||||
cat.name !== 'Autocompact buffer',
|
||||
)
|
||||
|
||||
if (visibleCategories.length > 0) {
|
||||
output += `### Estimated usage by category\n\n`
|
||||
output += `| Category | Tokens | Percentage |\n`
|
||||
output += `|----------|--------|------------|\n`
|
||||
|
||||
for (const cat of visibleCategories) {
|
||||
const percentDisplay = ((cat.tokens / rawMaxTokens) * 100).toFixed(1)
|
||||
output += `| ${cat.name} | ${formatTokens(cat.tokens)} | ${percentDisplay}% |\n`
|
||||
}
|
||||
|
||||
const freeSpaceCategory = categories.find(c => c.name === 'Free space')
|
||||
if (freeSpaceCategory && freeSpaceCategory.tokens > 0) {
|
||||
const percentDisplay = (
|
||||
(freeSpaceCategory.tokens / rawMaxTokens) *
|
||||
100
|
||||
).toFixed(1)
|
||||
output += `| Free space | ${formatTokens(freeSpaceCategory.tokens)} | ${percentDisplay}% |\n`
|
||||
}
|
||||
|
||||
const autocompactCategory = categories.find(
|
||||
c => c.name === 'Autocompact buffer',
|
||||
)
|
||||
if (autocompactCategory && autocompactCategory.tokens > 0) {
|
||||
const percentDisplay = (
|
||||
(autocompactCategory.tokens / rawMaxTokens) *
|
||||
100
|
||||
).toFixed(1)
|
||||
output += `| Autocompact buffer | ${formatTokens(autocompactCategory.tokens)} | ${percentDisplay}% |\n`
|
||||
}
|
||||
|
||||
output += `\n`
|
||||
}
|
||||
|
||||
// MCP tools
|
||||
if (mcpTools.length > 0) {
|
||||
output += `### MCP Tools\n\n`
|
||||
output += `| Tool | Server | Tokens |\n`
|
||||
output += `|------|--------|--------|\n`
|
||||
for (const tool of mcpTools) {
|
||||
output += `| ${tool.name} | ${tool.serverName} | ${formatTokens(tool.tokens)} |\n`
|
||||
}
|
||||
output += `\n`
|
||||
}
|
||||
|
||||
// System tools (ant-only)
|
||||
if (
|
||||
systemTools &&
|
||||
systemTools.length > 0 &&
|
||||
process.env.USER_TYPE === 'ant'
|
||||
) {
|
||||
output += `### [ANT-ONLY] System Tools\n\n`
|
||||
output += `| Tool | Tokens |\n`
|
||||
output += `|------|--------|\n`
|
||||
for (const tool of systemTools) {
|
||||
output += `| ${tool.name} | ${formatTokens(tool.tokens)} |\n`
|
||||
}
|
||||
output += `\n`
|
||||
}
|
||||
|
||||
// System prompt sections (ant-only)
|
||||
if (
|
||||
systemPromptSections &&
|
||||
systemPromptSections.length > 0 &&
|
||||
process.env.USER_TYPE === 'ant'
|
||||
) {
|
||||
output += `### [ANT-ONLY] System Prompt Sections\n\n`
|
||||
output += `| Section | Tokens |\n`
|
||||
output += `|---------|--------|\n`
|
||||
for (const section of systemPromptSections) {
|
||||
output += `| ${section.name} | ${formatTokens(section.tokens)} |\n`
|
||||
}
|
||||
output += `\n`
|
||||
}
|
||||
|
||||
// Custom agents
|
||||
if (agents.length > 0) {
|
||||
output += `### Custom Agents\n\n`
|
||||
output += `| Agent Type | Source | Tokens |\n`
|
||||
output += `|------------|--------|--------|\n`
|
||||
for (const agent of agents) {
|
||||
let sourceDisplay: string
|
||||
switch (agent.source) {
|
||||
case 'projectSettings':
|
||||
sourceDisplay = 'Project'
|
||||
break
|
||||
case 'userSettings':
|
||||
sourceDisplay = 'User'
|
||||
break
|
||||
case 'localSettings':
|
||||
sourceDisplay = 'Local'
|
||||
break
|
||||
case 'flagSettings':
|
||||
sourceDisplay = 'Flag'
|
||||
break
|
||||
case 'policySettings':
|
||||
sourceDisplay = 'Policy'
|
||||
break
|
||||
case 'plugin':
|
||||
sourceDisplay = 'Plugin'
|
||||
break
|
||||
case 'built-in':
|
||||
sourceDisplay = 'Built-in'
|
||||
break
|
||||
default:
|
||||
sourceDisplay = String(agent.source)
|
||||
}
|
||||
output += `| ${agent.agentType} | ${sourceDisplay} | ${formatTokens(agent.tokens)} |\n`
|
||||
}
|
||||
output += `\n`
|
||||
}
|
||||
|
||||
// Memory files
|
||||
if (memoryFiles.length > 0) {
|
||||
output += `### Memory Files\n\n`
|
||||
output += `| Type | Path | Tokens |\n`
|
||||
output += `|------|------|--------|\n`
|
||||
for (const file of memoryFiles) {
|
||||
output += `| ${file.type} | ${file.path} | ${formatTokens(file.tokens)} |\n`
|
||||
}
|
||||
output += `\n`
|
||||
}
|
||||
|
||||
// Skills
|
||||
if (skills && skills.tokens > 0 && skills.skillFrontmatter.length > 0) {
|
||||
output += `### Skills\n\n`
|
||||
output += `| Skill | Source | Tokens |\n`
|
||||
output += `|-------|--------|--------|\n`
|
||||
for (const skill of skills.skillFrontmatter) {
|
||||
output += `| ${skill.name} | ${getSourceDisplayName(skill.source)} | ${formatTokens(skill.tokens)} |\n`
|
||||
}
|
||||
output += `\n`
|
||||
}
|
||||
|
||||
// Message breakdown (ant-only)
|
||||
if (messageBreakdown && process.env.USER_TYPE === 'ant') {
|
||||
output += `### [ANT-ONLY] Message Breakdown\n\n`
|
||||
output += `| Category | Tokens |\n`
|
||||
output += `|----------|--------|\n`
|
||||
output += `| Tool calls | ${formatTokens(messageBreakdown.toolCallTokens)} |\n`
|
||||
output += `| Tool results | ${formatTokens(messageBreakdown.toolResultTokens)} |\n`
|
||||
output += `| Attachments | ${formatTokens(messageBreakdown.attachmentTokens)} |\n`
|
||||
output += `| Assistant messages (non-tool) | ${formatTokens(messageBreakdown.assistantMessageTokens)} |\n`
|
||||
output += `| User messages (non-tool-result) | ${formatTokens(messageBreakdown.userMessageTokens)} |\n`
|
||||
output += `\n`
|
||||
|
||||
if (messageBreakdown.toolCallsByType.length > 0) {
|
||||
output += `#### Top Tools\n\n`
|
||||
output += `| Tool | Call Tokens | Result Tokens |\n`
|
||||
output += `|------|-------------|---------------|\n`
|
||||
for (const tool of messageBreakdown.toolCallsByType) {
|
||||
output += `| ${tool.name} | ${formatTokens(tool.callTokens)} | ${formatTokens(tool.resultTokens)} |\n`
|
||||
}
|
||||
output += `\n`
|
||||
}
|
||||
|
||||
if (messageBreakdown.attachmentsByType.length > 0) {
|
||||
output += `#### Top Attachments\n\n`
|
||||
output += `| Attachment | Tokens |\n`
|
||||
output += `|------------|--------|\n`
|
||||
for (const attachment of messageBreakdown.attachmentsByType) {
|
||||
output += `| ${attachment.name} | ${formatTokens(attachment.tokens)} |\n`
|
||||
}
|
||||
output += `\n`
|
||||
}
|
||||
}
|
||||
|
||||
return output
|
||||
}
|
||||
64
src/commands/context/context.tsx
Normal file
64
src/commands/context/context.tsx
Normal file
File diff suppressed because one or more lines are too long
24
src/commands/context/index.ts
Normal file
24
src/commands/context/index.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import { getIsNonInteractiveSession } from '../../bootstrap/state.js'
|
||||
import type { Command } from '../../commands.js'
|
||||
|
||||
export const context: Command = {
|
||||
name: 'context',
|
||||
description: 'Visualize current context usage as a colored grid',
|
||||
isEnabled: () => !getIsNonInteractiveSession(),
|
||||
type: 'local-jsx',
|
||||
load: () => import('./context.js'),
|
||||
}
|
||||
|
||||
export const contextNonInteractive: Command = {
|
||||
type: 'local',
|
||||
name: 'context',
|
||||
supportsNonInteractive: true,
|
||||
description: 'Show current context usage',
|
||||
get isHidden() {
|
||||
return !getIsNonInteractiveSession()
|
||||
},
|
||||
isEnabled() {
|
||||
return getIsNonInteractiveSession()
|
||||
},
|
||||
load: () => import('./context-noninteractive.js'),
|
||||
}
|
||||
371
src/commands/copy/copy.tsx
Normal file
371
src/commands/copy/copy.tsx
Normal file
File diff suppressed because one or more lines are too long
15
src/commands/copy/index.ts
Normal file
15
src/commands/copy/index.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
/**
|
||||
* Copy command - minimal metadata only.
|
||||
* Implementation is lazy-loaded from copy.tsx to reduce startup time.
|
||||
*/
|
||||
import type { Command } from '../../commands.js'
|
||||
|
||||
const copy = {
|
||||
type: 'local-jsx',
|
||||
name: 'copy',
|
||||
description:
|
||||
"Copy Claude's last response to clipboard (or /copy N for the Nth-latest)",
|
||||
load: () => import('./copy.js'),
|
||||
} satisfies Command
|
||||
|
||||
export default copy
|
||||
24
src/commands/cost/cost.ts
Normal file
24
src/commands/cost/cost.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import { formatTotalCost } from '../../cost-tracker.js'
|
||||
import { currentLimits } from '../../services/claudeAiLimits.js'
|
||||
import type { LocalCommandCall } from '../../types/command.js'
|
||||
import { isClaudeAISubscriber } from '../../utils/auth.js'
|
||||
|
||||
export const call: LocalCommandCall = async () => {
|
||||
if (isClaudeAISubscriber()) {
|
||||
let value: string
|
||||
|
||||
if (currentLimits.isUsingOverage) {
|
||||
value =
|
||||
'You are currently using your overages to power your Claude Code usage. We will automatically switch you back to your subscription rate limits when they reset'
|
||||
} else {
|
||||
value =
|
||||
'You are currently using your subscription to power your Claude Code usage'
|
||||
}
|
||||
|
||||
if (process.env.USER_TYPE === 'ant') {
|
||||
value += `\n\n[ANT-ONLY] Showing cost anyway:\n ${formatTotalCost()}`
|
||||
}
|
||||
return { type: 'text', value }
|
||||
}
|
||||
return { type: 'text', value: formatTotalCost() }
|
||||
}
|
||||
23
src/commands/cost/index.ts
Normal file
23
src/commands/cost/index.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
/**
|
||||
* Cost command - minimal metadata only.
|
||||
* Implementation is lazy-loaded from cost.ts to reduce startup time.
|
||||
*/
|
||||
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
|
||||
65
src/commands/createMovedToPluginCommand.ts
Normal file
65
src/commands/createMovedToPluginCommand.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
import type { ContentBlockParam } from '@anthropic-ai/sdk/resources/messages.js'
|
||||
import type { Command } from '../commands.js'
|
||||
import type { ToolUseContext } from '../Tool.js'
|
||||
|
||||
type Options = {
|
||||
name: string
|
||||
description: string
|
||||
progressMessage: string
|
||||
pluginName: string
|
||||
pluginCommand: string
|
||||
/**
|
||||
* The prompt to use while the marketplace is private.
|
||||
* External users will get this prompt. Once the marketplace is public,
|
||||
* this parameter and the fallback logic can be removed.
|
||||
*/
|
||||
getPromptWhileMarketplaceIsPrivate: (
|
||||
args: string,
|
||||
context: ToolUseContext,
|
||||
) => Promise<ContentBlockParam[]>
|
||||
}
|
||||
|
||||
export function createMovedToPluginCommand({
|
||||
name,
|
||||
description,
|
||||
progressMessage,
|
||||
pluginName,
|
||||
pluginCommand,
|
||||
getPromptWhileMarketplaceIsPrivate,
|
||||
}: Options): Command {
|
||||
return {
|
||||
type: 'prompt',
|
||||
name,
|
||||
description,
|
||||
progressMessage,
|
||||
contentLength: 0, // Dynamic content
|
||||
userFacingName() {
|
||||
return name
|
||||
},
|
||||
source: 'builtin',
|
||||
async getPromptForCommand(
|
||||
args: string,
|
||||
context: ToolUseContext,
|
||||
): Promise<ContentBlockParam[]> {
|
||||
if (process.env.USER_TYPE === 'ant') {
|
||||
return [
|
||||
{
|
||||
type: 'text',
|
||||
text: `This command has been moved to a plugin. Tell the user:
|
||||
|
||||
1. To install the plugin, run:
|
||||
claude plugin install ${pluginName}@claude-code-marketplace
|
||||
|
||||
2. After installation, use /${pluginName}:${pluginCommand} to run this command
|
||||
|
||||
3. For more information, see: https://github.com/anthropics/claude-code-marketplace/blob/main/${pluginName}/README.md
|
||||
|
||||
Do not attempt to run the command. Simply inform the user about the plugin installation.`,
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
return getPromptWhileMarketplaceIsPrivate(args, context)
|
||||
},
|
||||
}
|
||||
}
|
||||
1
src/commands/ctx_viz/index.js
Normal file
1
src/commands/ctx_viz/index.js
Normal file
@@ -0,0 +1 @@
|
||||
export default { isEnabled: () => false, isHidden: true, name: 'stub' };
|
||||
1
src/commands/debug-tool-call/index.js
Normal file
1
src/commands/debug-tool-call/index.js
Normal file
@@ -0,0 +1 @@
|
||||
export default { isEnabled: () => false, isHidden: true, name: 'stub' };
|
||||
9
src/commands/desktop/desktop.tsx
Normal file
9
src/commands/desktop/desktop.tsx
Normal file
@@ -0,0 +1,9 @@
|
||||
import React from 'react';
|
||||
import type { CommandResultDisplay } from '../../commands.js';
|
||||
import { DesktopHandoff } from '../../components/DesktopHandoff.js';
|
||||
export async function call(onDone: (result?: string, options?: {
|
||||
display?: CommandResultDisplay;
|
||||
}) => void): Promise<React.ReactNode> {
|
||||
return <DesktopHandoff onDone={onDone} />;
|
||||
}
|
||||
//# sourceMappingURL=data:application/json;charset=utf-8;base64,eyJ2ZXJzaW9uIjozLCJuYW1lcyI6WyJSZWFjdCIsIkNvbW1hbmRSZXN1bHREaXNwbGF5IiwiRGVza3RvcEhhbmRvZmYiLCJjYWxsIiwib25Eb25lIiwicmVzdWx0Iiwib3B0aW9ucyIsImRpc3BsYXkiLCJQcm9taXNlIiwiUmVhY3ROb2RlIl0sInNvdXJjZXMiOlsiZGVza3RvcC50c3giXSwic291cmNlc0NvbnRlbnQiOlsiaW1wb3J0IFJlYWN0IGZyb20gJ3JlYWN0J1xuaW1wb3J0IHR5cGUgeyBDb21tYW5kUmVzdWx0RGlzcGxheSB9IGZyb20gJy4uLy4uL2NvbW1hbmRzLmpzJ1xuaW1wb3J0IHsgRGVza3RvcEhhbmRvZmYgfSBmcm9tICcuLi8uLi9jb21wb25lbnRzL0Rlc2t0b3BIYW5kb2ZmLmpzJ1xuXG5leHBvcnQgYXN5bmMgZnVuY3Rpb24gY2FsbChcbiAgb25Eb25lOiAoXG4gICAgcmVzdWx0Pzogc3RyaW5nLFxuICAgIG9wdGlvbnM/OiB7IGRpc3BsYXk/OiBDb21tYW5kUmVzdWx0RGlzcGxheSB9LFxuICApID0+IHZvaWQsXG4pOiBQcm9taXNlPFJlYWN0LlJlYWN0Tm9kZT4ge1xuICByZXR1cm4gPERlc2t0b3BIYW5kb2ZmIG9uRG9uZT17b25Eb25lfSAvPlxufVxuIl0sIm1hcHBpbmdzIjoiQUFBQSxPQUFPQSxLQUFLLE1BQU0sT0FBTztBQUN6QixjQUFjQyxvQkFBb0IsUUFBUSxtQkFBbUI7QUFDN0QsU0FBU0MsY0FBYyxRQUFRLG9DQUFvQztBQUVuRSxPQUFPLGVBQWVDLElBQUlBLENBQ3hCQyxNQUFNLEVBQUUsQ0FDTkMsTUFBZSxDQUFSLEVBQUUsTUFBTSxFQUNmQyxPQUE0QyxDQUFwQyxFQUFFO0VBQUVDLE9BQU8sQ0FBQyxFQUFFTixvQkFBb0I7QUFBQyxDQUFDLEVBQzVDLEdBQUcsSUFBSSxDQUNWLEVBQUVPLE9BQU8sQ0FBQ1IsS0FBSyxDQUFDUyxTQUFTLENBQUMsQ0FBQztFQUMxQixPQUFPLENBQUMsY0FBYyxDQUFDLE1BQU0sQ0FBQyxDQUFDTCxNQUFNLENBQUMsR0FBRztBQUMzQyIsImlnbm9yZUxpc3QiOltdfQ==
|
||||
26
src/commands/desktop/index.ts
Normal file
26
src/commands/desktop/index.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import type { Command } from '../../commands.js'
|
||||
|
||||
function isSupportedPlatform(): boolean {
|
||||
if (process.platform === 'darwin') {
|
||||
return true
|
||||
}
|
||||
if (process.platform === 'win32' && process.arch === 'x64') {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
const desktop = {
|
||||
type: 'local-jsx',
|
||||
name: 'desktop',
|
||||
aliases: ['app'],
|
||||
description: 'Continue the current session in Claude Desktop',
|
||||
availability: ['claude-ai'],
|
||||
isEnabled: isSupportedPlatform,
|
||||
get isHidden() {
|
||||
return !isSupportedPlatform()
|
||||
},
|
||||
load: () => import('./desktop.js'),
|
||||
} satisfies Command
|
||||
|
||||
export default desktop
|
||||
9
src/commands/diff/diff.tsx
Normal file
9
src/commands/diff/diff.tsx
Normal file
@@ -0,0 +1,9 @@
|
||||
import * as React from 'react';
|
||||
import type { LocalJSXCommandCall } from '../../types/command.js';
|
||||
export const call: LocalJSXCommandCall = async (onDone, context) => {
|
||||
const {
|
||||
DiffDialog
|
||||
} = await import('../../components/diff/DiffDialog.js');
|
||||
return <DiffDialog messages={context.messages} onDone={onDone} />;
|
||||
};
|
||||
//# sourceMappingURL=data:application/json;charset=utf-8;base64,eyJ2ZXJzaW9uIjozLCJuYW1lcyI6WyJSZWFjdCIsIkxvY2FsSlNYQ29tbWFuZENhbGwiLCJjYWxsIiwib25Eb25lIiwiY29udGV4dCIsIkRpZmZEaWFsb2ciLCJtZXNzYWdlcyJdLCJzb3VyY2VzIjpbImRpZmYudHN4Il0sInNvdXJjZXNDb250ZW50IjpbImltcG9ydCAqIGFzIFJlYWN0IGZyb20gJ3JlYWN0J1xuaW1wb3J0IHR5cGUgeyBMb2NhbEpTWENvbW1hbmRDYWxsIH0gZnJvbSAnLi4vLi4vdHlwZXMvY29tbWFuZC5qcydcblxuZXhwb3J0IGNvbnN0IGNhbGw6IExvY2FsSlNYQ29tbWFuZENhbGwgPSBhc3luYyAob25Eb25lLCBjb250ZXh0KSA9PiB7XG4gIGNvbnN0IHsgRGlmZkRpYWxvZyB9ID0gYXdhaXQgaW1wb3J0KCcuLi8uLi9jb21wb25lbnRzL2RpZmYvRGlmZkRpYWxvZy5qcycpXG4gIHJldHVybiA8RGlmZkRpYWxvZyBtZXNzYWdlcz17Y29udGV4dC5tZXNzYWdlc30gb25Eb25lPXtvbkRvbmV9IC8+XG59XG4iXSwibWFwcGluZ3MiOiJBQUFBLE9BQU8sS0FBS0EsS0FBSyxNQUFNLE9BQU87QUFDOUIsY0FBY0MsbUJBQW1CLFFBQVEsd0JBQXdCO0FBRWpFLE9BQU8sTUFBTUMsSUFBSSxFQUFFRCxtQkFBbUIsR0FBRyxNQUFBQyxDQUFPQyxNQUFNLEVBQUVDLE9BQU8sS0FBSztFQUNsRSxNQUFNO0lBQUVDO0VBQVcsQ0FBQyxHQUFHLE1BQU0sTUFBTSxDQUFDLHFDQUFxQyxDQUFDO0VBQzFFLE9BQU8sQ0FBQyxVQUFVLENBQUMsUUFBUSxDQUFDLENBQUNELE9BQU8sQ0FBQ0UsUUFBUSxDQUFDLENBQUMsTUFBTSxDQUFDLENBQUNILE1BQU0sQ0FBQyxHQUFHO0FBQ25FLENBQUMiLCJpZ25vcmVMaXN0IjpbXX0=
|
||||
8
src/commands/diff/index.ts
Normal file
8
src/commands/diff/index.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import type { Command } from '../../commands.js'
|
||||
|
||||
export default {
|
||||
type: 'local-jsx',
|
||||
name: 'diff',
|
||||
description: 'View uncommitted changes and per-turn diffs',
|
||||
load: () => import('./diff.js'),
|
||||
} satisfies Command
|
||||
7
src/commands/doctor/doctor.tsx
Normal file
7
src/commands/doctor/doctor.tsx
Normal file
@@ -0,0 +1,7 @@
|
||||
import React from 'react';
|
||||
import { Doctor } from '../../screens/Doctor.js';
|
||||
import type { LocalJSXCommandCall } from '../../types/command.js';
|
||||
export const call: LocalJSXCommandCall = (onDone, _context, _args) => {
|
||||
return Promise.resolve(<Doctor onDone={onDone} />);
|
||||
};
|
||||
//# sourceMappingURL=data:application/json;charset=utf-8;base64,eyJ2ZXJzaW9uIjozLCJuYW1lcyI6WyJSZWFjdCIsIkRvY3RvciIsIkxvY2FsSlNYQ29tbWFuZENhbGwiLCJjYWxsIiwib25Eb25lIiwiX2NvbnRleHQiLCJfYXJncyIsIlByb21pc2UiLCJyZXNvbHZlIl0sInNvdXJjZXMiOlsiZG9jdG9yLnRzeCJdLCJzb3VyY2VzQ29udGVudCI6WyJpbXBvcnQgUmVhY3QgZnJvbSAncmVhY3QnXG5pbXBvcnQgeyBEb2N0b3IgfSBmcm9tICcuLi8uLi9zY3JlZW5zL0RvY3Rvci5qcydcbmltcG9ydCB0eXBlIHsgTG9jYWxKU1hDb21tYW5kQ2FsbCB9IGZyb20gJy4uLy4uL3R5cGVzL2NvbW1hbmQuanMnXG5cbmV4cG9ydCBjb25zdCBjYWxsOiBMb2NhbEpTWENvbW1hbmRDYWxsID0gKG9uRG9uZSwgX2NvbnRleHQsIF9hcmdzKSA9PiB7XG4gIHJldHVybiBQcm9taXNlLnJlc29sdmUoPERvY3RvciBvbkRvbmU9e29uRG9uZX0gLz4pXG59XG4iXSwibWFwcGluZ3MiOiJBQUFBLE9BQU9BLEtBQUssTUFBTSxPQUFPO0FBQ3pCLFNBQVNDLE1BQU0sUUFBUSx5QkFBeUI7QUFDaEQsY0FBY0MsbUJBQW1CLFFBQVEsd0JBQXdCO0FBRWpFLE9BQU8sTUFBTUMsSUFBSSxFQUFFRCxtQkFBbUIsR0FBR0MsQ0FBQ0MsTUFBTSxFQUFFQyxRQUFRLEVBQUVDLEtBQUssS0FBSztFQUNwRSxPQUFPQyxPQUFPLENBQUNDLE9BQU8sQ0FBQyxDQUFDLE1BQU0sQ0FBQyxNQUFNLENBQUMsQ0FBQ0osTUFBTSxDQUFDLEdBQUcsQ0FBQztBQUNwRCxDQUFDIiwiaWdub3JlTGlzdCI6W119
|
||||
12
src/commands/doctor/index.ts
Normal file
12
src/commands/doctor/index.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import type { Command } from '../../commands.js'
|
||||
import { isEnvTruthy } from '../../utils/envUtils.js'
|
||||
|
||||
const doctor: Command = {
|
||||
name: 'doctor',
|
||||
description: 'Diagnose and verify your Claude Code installation and settings',
|
||||
isEnabled: () => !isEnvTruthy(process.env.DISABLE_DOCTOR_COMMAND),
|
||||
type: 'local-jsx',
|
||||
load: () => import('./doctor.js'),
|
||||
}
|
||||
|
||||
export default doctor
|
||||
183
src/commands/effort/effort.tsx
Normal file
183
src/commands/effort/effort.tsx
Normal file
File diff suppressed because one or more lines are too long
13
src/commands/effort/index.ts
Normal file
13
src/commands/effort/index.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import type { Command } from '../../commands.js'
|
||||
import { shouldInferenceConfigCommandBeImmediate } from '../../utils/immediateCommand.js'
|
||||
|
||||
export default {
|
||||
type: 'local-jsx',
|
||||
name: 'effort',
|
||||
description: 'Set effort level for model usage',
|
||||
argumentHint: '[low|medium|high|max|auto]',
|
||||
get immediate() {
|
||||
return shouldInferenceConfigCommandBeImmediate()
|
||||
},
|
||||
load: () => import('./effort.js'),
|
||||
} satisfies Command
|
||||
1
src/commands/env/index.js
vendored
Normal file
1
src/commands/env/index.js
vendored
Normal file
@@ -0,0 +1 @@
|
||||
export default { isEnabled: () => false, isHidden: true, name: 'stub' };
|
||||
33
src/commands/exit/exit.tsx
Normal file
33
src/commands/exit/exit.tsx
Normal file
@@ -0,0 +1,33 @@
|
||||
import { feature } from 'bun:bundle';
|
||||
import { spawnSync } from 'child_process';
|
||||
import sample from 'lodash-es/sample.js';
|
||||
import * as React from 'react';
|
||||
import { ExitFlow } from '../../components/ExitFlow.js';
|
||||
import type { LocalJSXCommandOnDone } from '../../types/command.js';
|
||||
import { isBgSession } from '../../utils/concurrentSessions.js';
|
||||
import { gracefulShutdown } from '../../utils/gracefulShutdown.js';
|
||||
import { getCurrentWorktreeSession } from '../../utils/worktree.js';
|
||||
const GOODBYE_MESSAGES = ['Goodbye!', 'See ya!', 'Bye!', 'Catch you later!'];
|
||||
function getRandomGoodbyeMessage(): string {
|
||||
return sample(GOODBYE_MESSAGES) ?? 'Goodbye!';
|
||||
}
|
||||
export async function call(onDone: LocalJSXCommandOnDone): Promise<React.ReactNode> {
|
||||
// Inside a `claude --bg` tmux session: detach instead of kill. The REPL
|
||||
// keeps running; `claude attach` can reconnect. Covers /exit, /quit,
|
||||
// ctrl+c, ctrl+d — all funnel through here via REPL's handleExit.
|
||||
if (feature('BG_SESSIONS') && isBgSession()) {
|
||||
onDone();
|
||||
spawnSync('tmux', ['detach-client'], {
|
||||
stdio: 'ignore'
|
||||
});
|
||||
return null;
|
||||
}
|
||||
const showWorktree = getCurrentWorktreeSession() !== null;
|
||||
if (showWorktree) {
|
||||
return <ExitFlow showWorktree={showWorktree} onDone={onDone} onCancel={() => onDone()} />;
|
||||
}
|
||||
onDone(getRandomGoodbyeMessage());
|
||||
await gracefulShutdown(0, 'prompt_input_exit');
|
||||
return null;
|
||||
}
|
||||
//# sourceMappingURL=data:application/json;charset=utf-8;base64,eyJ2ZXJzaW9uIjozLCJuYW1lcyI6WyJmZWF0dXJlIiwic3Bhd25TeW5jIiwic2FtcGxlIiwiUmVhY3QiLCJFeGl0RmxvdyIsIkxvY2FsSlNYQ29tbWFuZE9uRG9uZSIsImlzQmdTZXNzaW9uIiwiZ3JhY2VmdWxTaHV0ZG93biIsImdldEN1cnJlbnRXb3JrdHJlZVNlc3Npb24iLCJHT09EQllFX01FU1NBR0VTIiwiZ2V0UmFuZG9tR29vZGJ5ZU1lc3NhZ2UiLCJjYWxsIiwib25Eb25lIiwiUHJvbWlzZSIsIlJlYWN0Tm9kZSIsInN0ZGlvIiwic2hvd1dvcmt0cmVlIl0sInNvdXJjZXMiOlsiZXhpdC50c3giXSwic291cmNlc0NvbnRlbnQiOlsiaW1wb3J0IHsgZmVhdHVyZSB9IGZyb20gJ2J1bjpidW5kbGUnXG5pbXBvcnQgeyBzcGF3blN5bmMgfSBmcm9tICdjaGlsZF9wcm9jZXNzJ1xuaW1wb3J0IHNhbXBsZSBmcm9tICdsb2Rhc2gtZXMvc2FtcGxlLmpzJ1xuaW1wb3J0ICogYXMgUmVhY3QgZnJvbSAncmVhY3QnXG5pbXBvcnQgeyBFeGl0RmxvdyB9IGZyb20gJy4uLy4uL2NvbXBvbmVudHMvRXhpdEZsb3cuanMnXG5pbXBvcnQgdHlwZSB7IExvY2FsSlNYQ29tbWFuZE9uRG9uZSB9IGZyb20gJy4uLy4uL3R5cGVzL2NvbW1hbmQuanMnXG5pbXBvcnQgeyBpc0JnU2Vzc2lvbiB9IGZyb20gJy4uLy4uL3V0aWxzL2NvbmN1cnJlbnRTZXNzaW9ucy5qcydcbmltcG9ydCB7IGdyYWNlZnVsU2h1dGRvd24gfSBmcm9tICcuLi8uLi91dGlscy9ncmFjZWZ1bFNodXRkb3duLmpzJ1xuaW1wb3J0IHsgZ2V0Q3VycmVudFdvcmt0cmVlU2Vzc2lvbiB9IGZyb20gJy4uLy4uL3V0aWxzL3dvcmt0cmVlLmpzJ1xuXG5jb25zdCBHT09EQllFX01FU1NBR0VTID0gWydHb29kYnllIScsICdTZWUgeWEhJywgJ0J5ZSEnLCAnQ2F0Y2ggeW91IGxhdGVyISddXG5cbmZ1bmN0aW9uIGdldFJhbmRvbUdvb2RieWVNZXNzYWdlKCk6IHN0cmluZyB7XG4gIHJldHVybiBzYW1wbGUoR09PREJZRV9NRVNTQUdFUykgPz8gJ0dvb2RieWUhJ1xufVxuXG5leHBvcnQgYXN5bmMgZnVuY3Rpb24gY2FsbChcbiAgb25Eb25lOiBMb2NhbEpTWENvbW1hbmRPbkRvbmUsXG4pOiBQcm9taXNlPFJlYWN0LlJlYWN0Tm9kZT4ge1xuICAvLyBJbnNpZGUgYSBgY2xhdWRlIC0tYmdgIHRtdXggc2Vzc2lvbjogZGV0YWNoIGluc3RlYWQgb2Yga2lsbC4gVGhlIFJFUExcbiAgLy8ga2VlcHMgcnVubmluZzsgYGNsYXVkZSBhdHRhY2hgIGNhbiByZWNvbm5lY3QuIENvdmVycyAvZXhpdCwgL3F1aXQsXG4gIC8vIGN0cmwrYywgY3RybCtkIOKAlCBhbGwgZnVubmVsIHRocm91Z2ggaGVyZSB2aWEgUkVQTCdzIGhhbmRsZUV4aXQuXG4gIGlmIChmZWF0dXJlKCdCR19TRVNTSU9OUycpICYmIGlzQmdTZXNzaW9uKCkpIHtcbiAgICBvbkRvbmUoKVxuICAgIHNwYXduU3luYygndG11eCcsIFsnZGV0YWNoLWNsaWVudCddLCB7IHN0ZGlvOiAnaWdub3JlJyB9KVxuICAgIHJldHVybiBudWxsXG4gIH1cblxuICBjb25zdCBzaG93V29ya3RyZWUgPSBnZXRDdXJyZW50V29ya3RyZWVTZXNzaW9uKCkgIT09IG51bGxcblxuICBpZiAoc2hvd1dvcmt0cmVlKSB7XG4gICAgcmV0dXJuIChcbiAgICAgIDxFeGl0Rmxvd1xuICAgICAgICBzaG93V29ya3RyZWU9e3Nob3dXb3JrdHJlZX1cbiAgICAgICAgb25Eb25lPXtvbkRvbmV9XG4gICAgICAgIG9uQ2FuY2VsPXsoKSA9PiBvbkRvbmUoKX1cbiAgICAgIC8+XG4gICAgKVxuICB9XG5cbiAgb25Eb25lKGdldFJhbmRvbUdvb2RieWVNZXNzYWdlKCkpXG4gIGF3YWl0IGdyYWNlZnVsU2h1dGRvd24oMCwgJ3Byb21wdF9pbnB1dF9leGl0JylcbiAgcmV0dXJuIG51bGxcbn1cbiJdLCJtYXBwaW5ncyI6IkFBQUEsU0FBU0EsT0FBTyxRQUFRLFlBQVk7QUFDcEMsU0FBU0MsU0FBUyxRQUFRLGVBQWU7QUFDekMsT0FBT0MsTUFBTSxNQUFNLHFCQUFxQjtBQUN4QyxPQUFPLEtBQUtDLEtBQUssTUFBTSxPQUFPO0FBQzlCLFNBQVNDLFFBQVEsUUFBUSw4QkFBOEI7QUFDdkQsY0FBY0MscUJBQXFCLFFBQVEsd0JBQXdCO0FBQ25FLFNBQVNDLFdBQVcsUUFBUSxtQ0FBbUM7QUFDL0QsU0FBU0MsZ0JBQWdCLFFBQVEsaUNBQWlDO0FBQ2xFLFNBQVNDLHlCQUF5QixRQUFRLHlCQUF5QjtBQUVuRSxNQUFNQyxnQkFBZ0IsR0FBRyxDQUFDLFVBQVUsRUFBRSxTQUFTLEVBQUUsTUFBTSxFQUFFLGtCQUFrQixDQUFDO0FBRTVFLFNBQVNDLHVCQUF1QkEsQ0FBQSxDQUFFLEVBQUUsTUFBTSxDQUFDO0VBQ3pDLE9BQU9SLE1BQU0sQ0FBQ08sZ0JBQWdCLENBQUMsSUFBSSxVQUFVO0FBQy9DO0FBRUEsT0FBTyxlQUFlRSxJQUFJQSxDQUN4QkMsTUFBTSxFQUFFUCxxQkFBcUIsQ0FDOUIsRUFBRVEsT0FBTyxDQUFDVixLQUFLLENBQUNXLFNBQVMsQ0FBQyxDQUFDO0VBQzFCO0VBQ0E7RUFDQTtFQUNBLElBQUlkLE9BQU8sQ0FBQyxhQUFhLENBQUMsSUFBSU0sV0FBVyxDQUFDLENBQUMsRUFBRTtJQUMzQ00sTUFBTSxDQUFDLENBQUM7SUFDUlgsU0FBUyxDQUFDLE1BQU0sRUFBRSxDQUFDLGVBQWUsQ0FBQyxFQUFFO01BQUVjLEtBQUssRUFBRTtJQUFTLENBQUMsQ0FBQztJQUN6RCxPQUFPLElBQUk7RUFDYjtFQUVBLE1BQU1DLFlBQVksR0FBR1IseUJBQXlCLENBQUMsQ0FBQyxLQUFLLElBQUk7RUFFekQsSUFBSVEsWUFBWSxFQUFFO0lBQ2hCLE9BQ0UsQ0FBQyxRQUFRLENBQ1AsWUFBWSxDQUFDLENBQUNBLFlBQVksQ0FBQyxDQUMzQixNQUFNLENBQUMsQ0FBQ0osTUFBTSxDQUFDLENBQ2YsUUFBUSxDQUFDLENBQUMsTUFBTUEsTUFBTSxDQUFDLENBQUMsQ0FBQyxHQUN6QjtFQUVOO0VBRUFBLE1BQU0sQ0FBQ0YsdUJBQXVCLENBQUMsQ0FBQyxDQUFDO0VBQ2pDLE1BQU1ILGdCQUFnQixDQUFDLENBQUMsRUFBRSxtQkFBbUIsQ0FBQztFQUM5QyxPQUFPLElBQUk7QUFDYiIsImlnbm9yZUxpc3QiOltdfQ==
|
||||
12
src/commands/exit/index.ts
Normal file
12
src/commands/exit/index.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import type { Command } from '../../commands.js'
|
||||
|
||||
const exit = {
|
||||
type: 'local-jsx',
|
||||
name: 'exit',
|
||||
aliases: ['quit'],
|
||||
description: 'Exit the REPL',
|
||||
immediate: true,
|
||||
load: () => import('./exit.js'),
|
||||
} satisfies Command
|
||||
|
||||
export default exit
|
||||
91
src/commands/export/export.tsx
Normal file
91
src/commands/export/export.tsx
Normal file
File diff suppressed because one or more lines are too long
11
src/commands/export/index.ts
Normal file
11
src/commands/export/index.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import type { Command } from '../../commands.js'
|
||||
|
||||
const exportCommand = {
|
||||
type: 'local-jsx',
|
||||
name: 'export',
|
||||
description: 'Export the current conversation to a file or clipboard',
|
||||
argumentHint: '[filename]',
|
||||
load: () => import('./export.js'),
|
||||
} satisfies Command
|
||||
|
||||
export default exportCommand
|
||||
118
src/commands/extra-usage/extra-usage-core.ts
Normal file
118
src/commands/extra-usage/extra-usage-core.ts
Normal file
@@ -0,0 +1,118 @@
|
||||
import {
|
||||
checkAdminRequestEligibility,
|
||||
createAdminRequest,
|
||||
getMyAdminRequests,
|
||||
} from '../../services/api/adminRequests.js'
|
||||
import { invalidateOverageCreditGrantCache } from '../../services/api/overageCreditGrant.js'
|
||||
import { type ExtraUsage, fetchUtilization } from '../../services/api/usage.js'
|
||||
import { getSubscriptionType } from '../../utils/auth.js'
|
||||
import { hasClaudeAiBillingAccess } from '../../utils/billing.js'
|
||||
import { openBrowser } from '../../utils/browser.js'
|
||||
import { getGlobalConfig, saveGlobalConfig } from '../../utils/config.js'
|
||||
import { logError } from '../../utils/log.js'
|
||||
|
||||
type ExtraUsageResult =
|
||||
| { type: 'message'; value: string }
|
||||
| { type: 'browser-opened'; url: string; opened: boolean }
|
||||
|
||||
export async function runExtraUsage(): Promise<ExtraUsageResult> {
|
||||
if (!getGlobalConfig().hasVisitedExtraUsage) {
|
||||
saveGlobalConfig(prev => ({ ...prev, hasVisitedExtraUsage: true }))
|
||||
}
|
||||
// Invalidate only the current org's entry so a follow-up read refetches
|
||||
// the granted state. Separate from the visited flag since users may run
|
||||
// /extra-usage more than once while iterating on the claim flow.
|
||||
invalidateOverageCreditGrantCache()
|
||||
|
||||
const subscriptionType = getSubscriptionType()
|
||||
const isTeamOrEnterprise =
|
||||
subscriptionType === 'team' || subscriptionType === 'enterprise'
|
||||
const hasBillingAccess = hasClaudeAiBillingAccess()
|
||||
|
||||
if (!hasBillingAccess && isTeamOrEnterprise) {
|
||||
// Mirror apps/claude-ai useHasUnlimitedOverage(): if overage is enabled
|
||||
// with no monthly cap, there is nothing to request. On fetch error, fall
|
||||
// through and let the user ask (matching web's "err toward show" behavior).
|
||||
let extraUsage: ExtraUsage | null | undefined
|
||||
try {
|
||||
const utilization = await fetchUtilization()
|
||||
extraUsage = utilization?.extra_usage
|
||||
} catch (error) {
|
||||
logError(error as Error)
|
||||
}
|
||||
|
||||
if (extraUsage?.is_enabled && extraUsage.monthly_limit === null) {
|
||||
return {
|
||||
type: 'message',
|
||||
value:
|
||||
'Your organization already has unlimited extra usage. No request needed.',
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const eligibility = await checkAdminRequestEligibility('limit_increase')
|
||||
if (eligibility?.is_allowed === false) {
|
||||
return {
|
||||
type: 'message',
|
||||
value: 'Please contact your admin to manage extra usage settings.',
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
logError(error as Error)
|
||||
// If eligibility check fails, continue — the create endpoint will enforce if necessary
|
||||
}
|
||||
|
||||
try {
|
||||
const pendingOrDismissedRequests = await getMyAdminRequests(
|
||||
'limit_increase',
|
||||
['pending', 'dismissed'],
|
||||
)
|
||||
if (pendingOrDismissedRequests && pendingOrDismissedRequests.length > 0) {
|
||||
return {
|
||||
type: 'message',
|
||||
value:
|
||||
'You have already submitted a request for extra usage to your admin.',
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
logError(error as Error)
|
||||
// Fall through to creating a new request below
|
||||
}
|
||||
|
||||
try {
|
||||
await createAdminRequest({
|
||||
request_type: 'limit_increase',
|
||||
details: null,
|
||||
})
|
||||
return {
|
||||
type: 'message',
|
||||
value: extraUsage?.is_enabled
|
||||
? 'Request sent to your admin to increase extra usage.'
|
||||
: 'Request sent to your admin to enable extra usage.',
|
||||
}
|
||||
} catch (error) {
|
||||
logError(error as Error)
|
||||
// Fall through to generic message below
|
||||
}
|
||||
|
||||
return {
|
||||
type: 'message',
|
||||
value: 'Please contact your admin to manage extra usage settings.',
|
||||
}
|
||||
}
|
||||
|
||||
const url = isTeamOrEnterprise
|
||||
? 'https://claude.ai/admin-settings/usage'
|
||||
: 'https://claude.ai/settings/usage'
|
||||
|
||||
try {
|
||||
const opened = await openBrowser(url)
|
||||
return { type: 'browser-opened', url, opened }
|
||||
} catch (error) {
|
||||
logError(error as Error)
|
||||
return {
|
||||
type: 'message',
|
||||
value: `Failed to open browser. Please visit ${url} to manage extra usage.`,
|
||||
}
|
||||
}
|
||||
}
|
||||
16
src/commands/extra-usage/extra-usage-noninteractive.ts
Normal file
16
src/commands/extra-usage/extra-usage-noninteractive.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import { runExtraUsage } from './extra-usage-core.js'
|
||||
|
||||
export async function call(): Promise<{ type: 'text'; value: string }> {
|
||||
const result = await runExtraUsage()
|
||||
|
||||
if (result.type === 'message') {
|
||||
return { type: 'text', value: result.value }
|
||||
}
|
||||
|
||||
return {
|
||||
type: 'text',
|
||||
value: result.opened
|
||||
? `Browser opened to manage extra usage. If it didn't open, visit: ${result.url}`
|
||||
: `Please visit ${result.url} to manage extra usage.`,
|
||||
}
|
||||
}
|
||||
17
src/commands/extra-usage/extra-usage.tsx
Normal file
17
src/commands/extra-usage/extra-usage.tsx
Normal file
@@ -0,0 +1,17 @@
|
||||
import React from 'react';
|
||||
import type { LocalJSXCommandContext } from '../../commands.js';
|
||||
import type { LocalJSXCommandOnDone } from '../../types/command.js';
|
||||
import { Login } from '../login/login.js';
|
||||
import { runExtraUsage } from './extra-usage-core.js';
|
||||
export async function call(onDone: LocalJSXCommandOnDone, context: LocalJSXCommandContext): Promise<React.ReactNode | null> {
|
||||
const result = await runExtraUsage();
|
||||
if (result.type === 'message') {
|
||||
onDone(result.value);
|
||||
return null;
|
||||
}
|
||||
return <Login startingMessage={'Starting new login following /extra-usage. Exit with Ctrl-C to use existing account.'} onDone={success => {
|
||||
context.onChangeAPIKey();
|
||||
onDone(success ? 'Login successful' : 'Login interrupted');
|
||||
}} />;
|
||||
}
|
||||
//# sourceMappingURL=data:application/json;charset=utf-8;base64,eyJ2ZXJzaW9uIjozLCJuYW1lcyI6WyJSZWFjdCIsIkxvY2FsSlNYQ29tbWFuZENvbnRleHQiLCJMb2NhbEpTWENvbW1hbmRPbkRvbmUiLCJMb2dpbiIsInJ1bkV4dHJhVXNhZ2UiLCJjYWxsIiwib25Eb25lIiwiY29udGV4dCIsIlByb21pc2UiLCJSZWFjdE5vZGUiLCJyZXN1bHQiLCJ0eXBlIiwidmFsdWUiLCJzdWNjZXNzIiwib25DaGFuZ2VBUElLZXkiXSwic291cmNlcyI6WyJleHRyYS11c2FnZS50c3giXSwic291cmNlc0NvbnRlbnQiOlsiaW1wb3J0IFJlYWN0IGZyb20gJ3JlYWN0J1xuaW1wb3J0IHR5cGUgeyBMb2NhbEpTWENvbW1hbmRDb250ZXh0IH0gZnJvbSAnLi4vLi4vY29tbWFuZHMuanMnXG5pbXBvcnQgdHlwZSB7IExvY2FsSlNYQ29tbWFuZE9uRG9uZSB9IGZyb20gJy4uLy4uL3R5cGVzL2NvbW1hbmQuanMnXG5pbXBvcnQgeyBMb2dpbiB9IGZyb20gJy4uL2xvZ2luL2xvZ2luLmpzJ1xuaW1wb3J0IHsgcnVuRXh0cmFVc2FnZSB9IGZyb20gJy4vZXh0cmEtdXNhZ2UtY29yZS5qcydcblxuZXhwb3J0IGFzeW5jIGZ1bmN0aW9uIGNhbGwoXG4gIG9uRG9uZTogTG9jYWxKU1hDb21tYW5kT25Eb25lLFxuICBjb250ZXh0OiBMb2NhbEpTWENvbW1hbmRDb250ZXh0LFxuKTogUHJvbWlzZTxSZWFjdC5SZWFjdE5vZGUgfCBudWxsPiB7XG4gIGNvbnN0IHJlc3VsdCA9IGF3YWl0IHJ1bkV4dHJhVXNhZ2UoKVxuXG4gIGlmIChyZXN1bHQudHlwZSA9PT0gJ21lc3NhZ2UnKSB7XG4gICAgb25Eb25lKHJlc3VsdC52YWx1ZSlcbiAgICByZXR1cm4gbnVsbFxuICB9XG5cbiAgcmV0dXJuIChcbiAgICA8TG9naW5cbiAgICAgIHN0YXJ0aW5nTWVzc2FnZT17XG4gICAgICAgICdTdGFydGluZyBuZXcgbG9naW4gZm9sbG93aW5nIC9leHRyYS11c2FnZS4gRXhpdCB3aXRoIEN0cmwtQyB0byB1c2UgZXhpc3RpbmcgYWNjb3VudC4nXG4gICAgICB9XG4gICAgICBvbkRvbmU9e3N1Y2Nlc3MgPT4ge1xuICAgICAgICBjb250ZXh0Lm9uQ2hhbmdlQVBJS2V5KClcbiAgICAgICAgb25Eb25lKHN1Y2Nlc3MgPyAnTG9naW4gc3VjY2Vzc2Z1bCcgOiAnTG9naW4gaW50ZXJydXB0ZWQnKVxuICAgICAgfX1cbiAgICAvPlxuICApXG59XG4iXSwibWFwcGluZ3MiOiJBQUFBLE9BQU9BLEtBQUssTUFBTSxPQUFPO0FBQ3pCLGNBQWNDLHNCQUFzQixRQUFRLG1CQUFtQjtBQUMvRCxjQUFjQyxxQkFBcUIsUUFBUSx3QkFBd0I7QUFDbkUsU0FBU0MsS0FBSyxRQUFRLG1CQUFtQjtBQUN6QyxTQUFTQyxhQUFhLFFBQVEsdUJBQXVCO0FBRXJELE9BQU8sZUFBZUMsSUFBSUEsQ0FDeEJDLE1BQU0sRUFBRUoscUJBQXFCLEVBQzdCSyxPQUFPLEVBQUVOLHNCQUFzQixDQUNoQyxFQUFFTyxPQUFPLENBQUNSLEtBQUssQ0FBQ1MsU0FBUyxHQUFHLElBQUksQ0FBQyxDQUFDO0VBQ2pDLE1BQU1DLE1BQU0sR0FBRyxNQUFNTixhQUFhLENBQUMsQ0FBQztFQUVwQyxJQUFJTSxNQUFNLENBQUNDLElBQUksS0FBSyxTQUFTLEVBQUU7SUFDN0JMLE1BQU0sQ0FBQ0ksTUFBTSxDQUFDRSxLQUFLLENBQUM7SUFDcEIsT0FBTyxJQUFJO0VBQ2I7RUFFQSxPQUNFLENBQUMsS0FBSyxDQUNKLGVBQWUsQ0FBQyxDQUNkLHNGQUNGLENBQUMsQ0FDRCxNQUFNLENBQUMsQ0FBQ0MsT0FBTyxJQUFJO0lBQ2pCTixPQUFPLENBQUNPLGNBQWMsQ0FBQyxDQUFDO0lBQ3hCUixNQUFNLENBQUNPLE9BQU8sR0FBRyxrQkFBa0IsR0FBRyxtQkFBbUIsQ0FBQztFQUM1RCxDQUFDLENBQUMsR0FDRjtBQUVOIiwiaWdub3JlTGlzdCI6W119
|
||||
31
src/commands/extra-usage/index.ts
Normal file
31
src/commands/extra-usage/index.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import { getIsNonInteractiveSession } from '../../bootstrap/state.js'
|
||||
import type { Command } from '../../commands.js'
|
||||
import { isOverageProvisioningAllowed } from '../../utils/auth.js'
|
||||
import { isEnvTruthy } from '../../utils/envUtils.js'
|
||||
|
||||
function isExtraUsageAllowed(): boolean {
|
||||
if (isEnvTruthy(process.env.DISABLE_EXTRA_USAGE_COMMAND)) {
|
||||
return false
|
||||
}
|
||||
return isOverageProvisioningAllowed()
|
||||
}
|
||||
|
||||
export const extraUsage = {
|
||||
type: 'local-jsx',
|
||||
name: 'extra-usage',
|
||||
description: 'Configure extra usage to keep working when limits are hit',
|
||||
isEnabled: () => isExtraUsageAllowed() && !getIsNonInteractiveSession(),
|
||||
load: () => import('./extra-usage.js'),
|
||||
} satisfies Command
|
||||
|
||||
export const extraUsageNonInteractive = {
|
||||
type: 'local',
|
||||
name: 'extra-usage',
|
||||
supportsNonInteractive: true,
|
||||
description: 'Configure extra usage to keep working when limits are hit',
|
||||
isEnabled: () => isExtraUsageAllowed() && getIsNonInteractiveSession(),
|
||||
get isHidden() {
|
||||
return !getIsNonInteractiveSession()
|
||||
},
|
||||
load: () => import('./extra-usage-noninteractive.js'),
|
||||
} satisfies Command
|
||||
269
src/commands/fast/fast.tsx
Normal file
269
src/commands/fast/fast.tsx
Normal file
File diff suppressed because one or more lines are too long
26
src/commands/fast/index.ts
Normal file
26
src/commands/fast/index.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import type { Command } from '../../commands.js'
|
||||
import {
|
||||
FAST_MODE_MODEL_DISPLAY,
|
||||
isFastModeEnabled,
|
||||
} from '../../utils/fastMode.js'
|
||||
import { shouldInferenceConfigCommandBeImmediate } from '../../utils/immediateCommand.js'
|
||||
|
||||
const fast = {
|
||||
type: 'local-jsx',
|
||||
name: 'fast',
|
||||
get description() {
|
||||
return `Toggle fast mode (${FAST_MODE_MODEL_DISPLAY} only)`
|
||||
},
|
||||
availability: ['claude-ai', 'console'],
|
||||
isEnabled: () => isFastModeEnabled(),
|
||||
get isHidden() {
|
||||
return !isFastModeEnabled()
|
||||
},
|
||||
argumentHint: '[on|off]',
|
||||
get immediate() {
|
||||
return shouldInferenceConfigCommandBeImmediate()
|
||||
},
|
||||
load: () => import('./fast.js'),
|
||||
} satisfies Command
|
||||
|
||||
export default fast
|
||||
25
src/commands/feedback/feedback.tsx
Normal file
25
src/commands/feedback/feedback.tsx
Normal file
@@ -0,0 +1,25 @@
|
||||
import * as React from 'react';
|
||||
import type { CommandResultDisplay, LocalJSXCommandContext } from '../../commands.js';
|
||||
import { Feedback } from '../../components/Feedback.js';
|
||||
import type { LocalJSXCommandOnDone } from '../../types/command.js';
|
||||
import type { Message } from '../../types/message.js';
|
||||
|
||||
// Shared function to render the Feedback component
|
||||
export function renderFeedbackComponent(onDone: (result?: string, options?: {
|
||||
display?: CommandResultDisplay;
|
||||
}) => void, abortSignal: AbortSignal, messages: Message[], initialDescription: string = '', backgroundTasks: {
|
||||
[taskId: string]: {
|
||||
type: string;
|
||||
identity?: {
|
||||
agentId: string;
|
||||
};
|
||||
messages?: Message[];
|
||||
};
|
||||
} = {}): React.ReactNode {
|
||||
return <Feedback abortSignal={abortSignal} messages={messages} initialDescription={initialDescription} onDone={onDone} backgroundTasks={backgroundTasks} />;
|
||||
}
|
||||
export async function call(onDone: LocalJSXCommandOnDone, context: LocalJSXCommandContext, args?: string): Promise<React.ReactNode> {
|
||||
const initialDescription = args || '';
|
||||
return renderFeedbackComponent(onDone, context.abortController.signal, context.messages, initialDescription);
|
||||
}
|
||||
//# sourceMappingURL=data:application/json;charset=utf-8;base64,eyJ2ZXJzaW9uIjozLCJuYW1lcyI6WyJSZWFjdCIsIkNvbW1hbmRSZXN1bHREaXNwbGF5IiwiTG9jYWxKU1hDb21tYW5kQ29udGV4dCIsIkZlZWRiYWNrIiwiTG9jYWxKU1hDb21tYW5kT25Eb25lIiwiTWVzc2FnZSIsInJlbmRlckZlZWRiYWNrQ29tcG9uZW50Iiwib25Eb25lIiwicmVzdWx0Iiwib3B0aW9ucyIsImRpc3BsYXkiLCJhYm9ydFNpZ25hbCIsIkFib3J0U2lnbmFsIiwibWVzc2FnZXMiLCJpbml0aWFsRGVzY3JpcHRpb24iLCJiYWNrZ3JvdW5kVGFza3MiLCJ0YXNrSWQiLCJ0eXBlIiwiaWRlbnRpdHkiLCJhZ2VudElkIiwiUmVhY3ROb2RlIiwiY2FsbCIsImNvbnRleHQiLCJhcmdzIiwiUHJvbWlzZSIsImFib3J0Q29udHJvbGxlciIsInNpZ25hbCJdLCJzb3VyY2VzIjpbImZlZWRiYWNrLnRzeCJdLCJzb3VyY2VzQ29udGVudCI6WyJpbXBvcnQgKiBhcyBSZWFjdCBmcm9tICdyZWFjdCdcbmltcG9ydCB0eXBlIHtcbiAgQ29tbWFuZFJlc3VsdERpc3BsYXksXG4gIExvY2FsSlNYQ29tbWFuZENvbnRleHQsXG59IGZyb20gJy4uLy4uL2NvbW1hbmRzLmpzJ1xuaW1wb3J0IHsgRmVlZGJhY2sgfSBmcm9tICcuLi8uLi9jb21wb25lbnRzL0ZlZWRiYWNrLmpzJ1xuaW1wb3J0IHR5cGUgeyBMb2NhbEpTWENvbW1hbmRPbkRvbmUgfSBmcm9tICcuLi8uLi90eXBlcy9jb21tYW5kLmpzJ1xuaW1wb3J0IHR5cGUgeyBNZXNzYWdlIH0gZnJvbSAnLi4vLi4vdHlwZXMvbWVzc2FnZS5qcydcblxuLy8gU2hhcmVkIGZ1bmN0aW9uIHRvIHJlbmRlciB0aGUgRmVlZGJhY2sgY29tcG9uZW50XG5leHBvcnQgZnVuY3Rpb24gcmVuZGVyRmVlZGJhY2tDb21wb25lbnQoXG4gIG9uRG9uZTogKFxuICAgIHJlc3VsdD86IHN0cmluZyxcbiAgICBvcHRpb25zPzogeyBkaXNwbGF5PzogQ29tbWFuZFJlc3VsdERpc3BsYXkgfSxcbiAgKSA9PiB2b2lkLFxuICBhYm9ydFNpZ25hbDogQWJvcnRTaWduYWwsXG4gIG1lc3NhZ2VzOiBNZXNzYWdlW10sXG4gIGluaXRpYWxEZXNjcmlwdGlvbjogc3RyaW5nID0gJycsXG4gIGJhY2tncm91bmRUYXNrczoge1xuICAgIFt0YXNrSWQ6IHN0cmluZ106IHtcbiAgICAgIHR5cGU6IHN0cmluZ1xuICAgICAgaWRlbnRpdHk/OiB7IGFnZW50SWQ6IHN0cmluZyB9XG4gICAgICBtZXNzYWdlcz86IE1lc3NhZ2VbXVxuICAgIH1cbiAgfSA9IHt9LFxuKTogUmVhY3QuUmVhY3ROb2RlIHtcbiAgcmV0dXJuIChcbiAgICA8RmVlZGJhY2tcbiAgICAgIGFib3J0U2lnbmFsPXthYm9ydFNpZ25hbH1cbiAgICAgIG1lc3NhZ2VzPXttZXNzYWdlc31cbiAgICAgIGluaXRpYWxEZXNjcmlwdGlvbj17aW5pdGlhbERlc2NyaXB0aW9ufVxuICAgICAgb25Eb25lPXtvbkRvbmV9XG4gICAgICBiYWNrZ3JvdW5kVGFza3M9e2JhY2tncm91bmRUYXNrc31cbiAgICAvPlxuICApXG59XG5cbmV4cG9ydCBhc3luYyBmdW5jdGlvbiBjYWxsKFxuICBvbkRvbmU6IExvY2FsSlNYQ29tbWFuZE9uRG9uZSxcbiAgY29udGV4dDogTG9jYWxKU1hDb21tYW5kQ29udGV4dCxcbiAgYXJncz86IHN0cmluZyxcbik6IFByb21pc2U8UmVhY3QuUmVhY3ROb2RlPiB7XG4gIGNvbnN0IGluaXRpYWxEZXNjcmlwdGlvbiA9IGFyZ3MgfHwgJydcbiAgcmV0dXJuIHJlbmRlckZlZWRiYWNrQ29tcG9uZW50KFxuICAgIG9uRG9uZSxcbiAgICBjb250ZXh0LmFib3J0Q29udHJvbGxlci5zaWduYWwsXG4gICAgY29udGV4dC5tZXNzYWdlcyxcbiAgICBpbml0aWFsRGVzY3JpcHRpb24sXG4gIClcbn1cbiJdLCJtYXBwaW5ncyI6IkFBQUEsT0FBTyxLQUFLQSxLQUFLLE1BQU0sT0FBTztBQUM5QixjQUNFQyxvQkFBb0IsRUFDcEJDLHNCQUFzQixRQUNqQixtQkFBbUI7QUFDMUIsU0FBU0MsUUFBUSxRQUFRLDhCQUE4QjtBQUN2RCxjQUFjQyxxQkFBcUIsUUFBUSx3QkFBd0I7QUFDbkUsY0FBY0MsT0FBTyxRQUFRLHdCQUF3Qjs7QUFFckQ7QUFDQSxPQUFPLFNBQVNDLHVCQUF1QkEsQ0FDckNDLE1BQU0sRUFBRSxDQUNOQyxNQUFlLENBQVIsRUFBRSxNQUFNLEVBQ2ZDLE9BQTRDLENBQXBDLEVBQUU7RUFBRUMsT0FBTyxDQUFDLEVBQUVULG9CQUFvQjtBQUFDLENBQUMsRUFDNUMsR0FBRyxJQUFJLEVBQ1RVLFdBQVcsRUFBRUMsV0FBVyxFQUN4QkMsUUFBUSxFQUFFUixPQUFPLEVBQUUsRUFDbkJTLGtCQUFrQixFQUFFLE1BQU0sR0FBRyxFQUFFLEVBQy9CQyxlQUFlLEVBQUU7RUFDZixDQUFDQyxNQUFNLEVBQUUsTUFBTSxDQUFDLEVBQUU7SUFDaEJDLElBQUksRUFBRSxNQUFNO0lBQ1pDLFFBQVEsQ0FBQyxFQUFFO01BQUVDLE9BQU8sRUFBRSxNQUFNO0lBQUMsQ0FBQztJQUM5Qk4sUUFBUSxDQUFDLEVBQUVSLE9BQU8sRUFBRTtFQUN0QixDQUFDO0FBQ0gsQ0FBQyxHQUFHLENBQUMsQ0FBQyxDQUNQLEVBQUVMLEtBQUssQ0FBQ29CLFNBQVMsQ0FBQztFQUNqQixPQUNFLENBQUMsUUFBUSxDQUNQLFdBQVcsQ0FBQyxDQUFDVCxXQUFXLENBQUMsQ0FDekIsUUFBUSxDQUFDLENBQUNFLFFBQVEsQ0FBQyxDQUNuQixrQkFBa0IsQ0FBQyxDQUFDQyxrQkFBa0IsQ0FBQyxDQUN2QyxNQUFNLENBQUMsQ0FBQ1AsTUFBTSxDQUFDLENBQ2YsZUFBZSxDQUFDLENBQUNRLGVBQWUsQ0FBQyxHQUNqQztBQUVOO0FBRUEsT0FBTyxlQUFlTSxJQUFJQSxDQUN4QmQsTUFBTSxFQUFFSCxxQkFBcUIsRUFDN0JrQixPQUFPLEVBQUVwQixzQkFBc0IsRUFDL0JxQixJQUFhLENBQVIsRUFBRSxNQUFNLENBQ2QsRUFBRUMsT0FBTyxDQUFDeEIsS0FBSyxDQUFDb0IsU0FBUyxDQUFDLENBQUM7RUFDMUIsTUFBTU4sa0JBQWtCLEdBQUdTLElBQUksSUFBSSxFQUFFO0VBQ3JDLE9BQU9qQix1QkFBdUIsQ0FDNUJDLE1BQU0sRUFDTmUsT0FBTyxDQUFDRyxlQUFlLENBQUNDLE1BQU0sRUFDOUJKLE9BQU8sQ0FBQ1QsUUFBUSxFQUNoQkMsa0JBQ0YsQ0FBQztBQUNIIiwiaWdub3JlTGlzdCI6W119
|
||||
26
src/commands/feedback/index.ts
Normal file
26
src/commands/feedback/index.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import type { Command } from '../../commands.js'
|
||||
import { isPolicyAllowed } from '../../services/policyLimits/index.js'
|
||||
import { isEnvTruthy } from '../../utils/envUtils.js'
|
||||
import { isEssentialTrafficOnly } from '../../utils/privacyLevel.js'
|
||||
|
||||
const feedback = {
|
||||
aliases: ['bug'],
|
||||
type: 'local-jsx',
|
||||
name: 'feedback',
|
||||
description: `Submit feedback about Claude Code`,
|
||||
argumentHint: '[report]',
|
||||
isEnabled: () =>
|
||||
!(
|
||||
isEnvTruthy(process.env.CLAUDE_CODE_USE_BEDROCK) ||
|
||||
isEnvTruthy(process.env.CLAUDE_CODE_USE_VERTEX) ||
|
||||
isEnvTruthy(process.env.CLAUDE_CODE_USE_FOUNDRY) ||
|
||||
isEnvTruthy(process.env.DISABLE_FEEDBACK_COMMAND) ||
|
||||
isEnvTruthy(process.env.DISABLE_BUG_COMMAND) ||
|
||||
isEssentialTrafficOnly() ||
|
||||
process.env.USER_TYPE === 'ant' ||
|
||||
!isPolicyAllowed('allow_product_feedback')
|
||||
),
|
||||
load: () => import('./feedback.js'),
|
||||
} satisfies Command
|
||||
|
||||
export default feedback
|
||||
19
src/commands/files/files.ts
Normal file
19
src/commands/files/files.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import { relative } from 'path'
|
||||
import type { ToolUseContext } from '../../Tool.js'
|
||||
import type { LocalCommandResult } from '../../types/command.js'
|
||||
import { getCwd } from '../../utils/cwd.js'
|
||||
import { cacheKeys } from '../../utils/fileStateCache.js'
|
||||
|
||||
export async function call(
|
||||
_args: string,
|
||||
context: ToolUseContext,
|
||||
): Promise<LocalCommandResult> {
|
||||
const files = context.readFileState ? cacheKeys(context.readFileState) : []
|
||||
|
||||
if (files.length === 0) {
|
||||
return { type: 'text' as const, value: 'No files in context' }
|
||||
}
|
||||
|
||||
const fileList = files.map(file => relative(getCwd(), file)).join('\n')
|
||||
return { type: 'text' as const, value: `Files in context:\n${fileList}` }
|
||||
}
|
||||
12
src/commands/files/index.ts
Normal file
12
src/commands/files/index.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import type { Command } from '../../commands.js'
|
||||
|
||||
const files = {
|
||||
type: 'local',
|
||||
name: 'files',
|
||||
description: 'List all files currently in context',
|
||||
isEnabled: () => process.env.USER_TYPE === 'ant',
|
||||
supportsNonInteractive: true,
|
||||
load: () => import('./files.js'),
|
||||
} satisfies Command
|
||||
|
||||
export default files
|
||||
1
src/commands/good-claude/index.js
Normal file
1
src/commands/good-claude/index.js
Normal file
@@ -0,0 +1 @@
|
||||
export default { isEnabled: () => false, isHidden: true, name: 'stub' };
|
||||
17
src/commands/heapdump/heapdump.ts
Normal file
17
src/commands/heapdump/heapdump.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import { performHeapDump } from '../../utils/heapDumpService.js'
|
||||
|
||||
export async function call(): Promise<{ type: 'text'; value: string }> {
|
||||
const result = await performHeapDump()
|
||||
|
||||
if (!result.success) {
|
||||
return {
|
||||
type: 'text',
|
||||
value: `Failed to create heap dump: ${result.error}`,
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
type: 'text',
|
||||
value: `${result.heapPath}\n${result.diagPath}`,
|
||||
}
|
||||
}
|
||||
12
src/commands/heapdump/index.ts
Normal file
12
src/commands/heapdump/index.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import type { Command } from '../../commands.js'
|
||||
|
||||
const heapDump = {
|
||||
type: 'local',
|
||||
name: 'heapdump',
|
||||
description: 'Dump the JS heap to ~/Desktop',
|
||||
isHidden: true,
|
||||
supportsNonInteractive: true,
|
||||
load: () => import('./heapdump.js'),
|
||||
} satisfies Command
|
||||
|
||||
export default heapDump
|
||||
11
src/commands/help/help.tsx
Normal file
11
src/commands/help/help.tsx
Normal file
@@ -0,0 +1,11 @@
|
||||
import * as React from 'react';
|
||||
import { HelpV2 } from '../../components/HelpV2/HelpV2.js';
|
||||
import type { LocalJSXCommandCall } from '../../types/command.js';
|
||||
export const call: LocalJSXCommandCall = async (onDone, {
|
||||
options: {
|
||||
commands
|
||||
}
|
||||
}) => {
|
||||
return <HelpV2 commands={commands} onClose={onDone} />;
|
||||
};
|
||||
//# sourceMappingURL=data:application/json;charset=utf-8;base64,eyJ2ZXJzaW9uIjozLCJuYW1lcyI6WyJSZWFjdCIsIkhlbHBWMiIsIkxvY2FsSlNYQ29tbWFuZENhbGwiLCJjYWxsIiwib25Eb25lIiwib3B0aW9ucyIsImNvbW1hbmRzIl0sInNvdXJjZXMiOlsiaGVscC50c3giXSwic291cmNlc0NvbnRlbnQiOlsiaW1wb3J0ICogYXMgUmVhY3QgZnJvbSAncmVhY3QnXG5pbXBvcnQgeyBIZWxwVjIgfSBmcm9tICcuLi8uLi9jb21wb25lbnRzL0hlbHBWMi9IZWxwVjIuanMnXG5pbXBvcnQgdHlwZSB7IExvY2FsSlNYQ29tbWFuZENhbGwgfSBmcm9tICcuLi8uLi90eXBlcy9jb21tYW5kLmpzJ1xuXG5leHBvcnQgY29uc3QgY2FsbDogTG9jYWxKU1hDb21tYW5kQ2FsbCA9IGFzeW5jIChcbiAgb25Eb25lLFxuICB7IG9wdGlvbnM6IHsgY29tbWFuZHMgfSB9LFxuKSA9PiB7XG4gIHJldHVybiA8SGVscFYyIGNvbW1hbmRzPXtjb21tYW5kc30gb25DbG9zZT17b25Eb25lfSAvPlxufVxuIl0sIm1hcHBpbmdzIjoiQUFBQSxPQUFPLEtBQUtBLEtBQUssTUFBTSxPQUFPO0FBQzlCLFNBQVNDLE1BQU0sUUFBUSxtQ0FBbUM7QUFDMUQsY0FBY0MsbUJBQW1CLFFBQVEsd0JBQXdCO0FBRWpFLE9BQU8sTUFBTUMsSUFBSSxFQUFFRCxtQkFBbUIsR0FBRyxNQUFBQyxDQUN2Q0MsTUFBTSxFQUNOO0VBQUVDLE9BQU8sRUFBRTtJQUFFQztFQUFTO0FBQUUsQ0FBQyxLQUN0QjtFQUNILE9BQU8sQ0FBQyxNQUFNLENBQUMsUUFBUSxDQUFDLENBQUNBLFFBQVEsQ0FBQyxDQUFDLE9BQU8sQ0FBQyxDQUFDRixNQUFNLENBQUMsR0FBRztBQUN4RCxDQUFDIiwiaWdub3JlTGlzdCI6W119
|
||||
10
src/commands/help/index.ts
Normal file
10
src/commands/help/index.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import type { Command } from '../../commands.js'
|
||||
|
||||
const help = {
|
||||
type: 'local-jsx',
|
||||
name: 'help',
|
||||
description: 'Show help and available commands',
|
||||
load: () => import('./help.js'),
|
||||
} satisfies Command
|
||||
|
||||
export default help
|
||||
13
src/commands/hooks/hooks.tsx
Normal file
13
src/commands/hooks/hooks.tsx
Normal file
@@ -0,0 +1,13 @@
|
||||
import * as React from 'react';
|
||||
import { HooksConfigMenu } from '../../components/hooks/HooksConfigMenu.js';
|
||||
import { logEvent } from '../../services/analytics/index.js';
|
||||
import { getTools } from '../../tools.js';
|
||||
import type { LocalJSXCommandCall } from '../../types/command.js';
|
||||
export const call: LocalJSXCommandCall = async (onDone, context) => {
|
||||
logEvent('tengu_hooks_command', {});
|
||||
const appState = context.getAppState();
|
||||
const permissionContext = appState.toolPermissionContext;
|
||||
const toolNames = getTools(permissionContext).map(tool => tool.name);
|
||||
return <HooksConfigMenu toolNames={toolNames} onExit={onDone} />;
|
||||
};
|
||||
//# sourceMappingURL=data:application/json;charset=utf-8;base64,eyJ2ZXJzaW9uIjozLCJuYW1lcyI6WyJSZWFjdCIsIkhvb2tzQ29uZmlnTWVudSIsImxvZ0V2ZW50IiwiZ2V0VG9vbHMiLCJMb2NhbEpTWENvbW1hbmRDYWxsIiwiY2FsbCIsIm9uRG9uZSIsImNvbnRleHQiLCJhcHBTdGF0ZSIsImdldEFwcFN0YXRlIiwicGVybWlzc2lvbkNvbnRleHQiLCJ0b29sUGVybWlzc2lvbkNvbnRleHQiLCJ0b29sTmFtZXMiLCJtYXAiLCJ0b29sIiwibmFtZSJdLCJzb3VyY2VzIjpbImhvb2tzLnRzeCJdLCJzb3VyY2VzQ29udGVudCI6WyJpbXBvcnQgKiBhcyBSZWFjdCBmcm9tICdyZWFjdCdcbmltcG9ydCB7IEhvb2tzQ29uZmlnTWVudSB9IGZyb20gJy4uLy4uL2NvbXBvbmVudHMvaG9va3MvSG9va3NDb25maWdNZW51LmpzJ1xuaW1wb3J0IHsgbG9nRXZlbnQgfSBmcm9tICcuLi8uLi9zZXJ2aWNlcy9hbmFseXRpY3MvaW5kZXguanMnXG5pbXBvcnQgeyBnZXRUb29scyB9IGZyb20gJy4uLy4uL3Rvb2xzLmpzJ1xuaW1wb3J0IHR5cGUgeyBMb2NhbEpTWENvbW1hbmRDYWxsIH0gZnJvbSAnLi4vLi4vdHlwZXMvY29tbWFuZC5qcydcblxuZXhwb3J0IGNvbnN0IGNhbGw6IExvY2FsSlNYQ29tbWFuZENhbGwgPSBhc3luYyAob25Eb25lLCBjb250ZXh0KSA9PiB7XG4gIGxvZ0V2ZW50KCd0ZW5ndV9ob29rc19jb21tYW5kJywge30pXG4gIGNvbnN0IGFwcFN0YXRlID0gY29udGV4dC5nZXRBcHBTdGF0ZSgpXG4gIGNvbnN0IHBlcm1pc3Npb25Db250ZXh0ID0gYXBwU3RhdGUudG9vbFBlcm1pc3Npb25Db250ZXh0XG4gIGNvbnN0IHRvb2xOYW1lcyA9IGdldFRvb2xzKHBlcm1pc3Npb25Db250ZXh0KS5tYXAodG9vbCA9PiB0b29sLm5hbWUpXG4gIHJldHVybiA8SG9va3NDb25maWdNZW51IHRvb2xOYW1lcz17dG9vbE5hbWVzfSBvbkV4aXQ9e29uRG9uZX0gLz5cbn1cbiJdLCJtYXBwaW5ncyI6IkFBQUEsT0FBTyxLQUFLQSxLQUFLLE1BQU0sT0FBTztBQUM5QixTQUFTQyxlQUFlLFFBQVEsMkNBQTJDO0FBQzNFLFNBQVNDLFFBQVEsUUFBUSxtQ0FBbUM7QUFDNUQsU0FBU0MsUUFBUSxRQUFRLGdCQUFnQjtBQUN6QyxjQUFjQyxtQkFBbUIsUUFBUSx3QkFBd0I7QUFFakUsT0FBTyxNQUFNQyxJQUFJLEVBQUVELG1CQUFtQixHQUFHLE1BQUFDLENBQU9DLE1BQU0sRUFBRUMsT0FBTyxLQUFLO0VBQ2xFTCxRQUFRLENBQUMscUJBQXFCLEVBQUUsQ0FBQyxDQUFDLENBQUM7RUFDbkMsTUFBTU0sUUFBUSxHQUFHRCxPQUFPLENBQUNFLFdBQVcsQ0FBQyxDQUFDO0VBQ3RDLE1BQU1DLGlCQUFpQixHQUFHRixRQUFRLENBQUNHLHFCQUFxQjtFQUN4RCxNQUFNQyxTQUFTLEdBQUdULFFBQVEsQ0FBQ08saUJBQWlCLENBQUMsQ0FBQ0csR0FBRyxDQUFDQyxJQUFJLElBQUlBLElBQUksQ0FBQ0MsSUFBSSxDQUFDO0VBQ3BFLE9BQU8sQ0FBQyxlQUFlLENBQUMsU0FBUyxDQUFDLENBQUNILFNBQVMsQ0FBQyxDQUFDLE1BQU0sQ0FBQyxDQUFDTixNQUFNLENBQUMsR0FBRztBQUNsRSxDQUFDIiwiaWdub3JlTGlzdCI6W119
|
||||
11
src/commands/hooks/index.ts
Normal file
11
src/commands/hooks/index.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import type { Command } from '../../commands.js'
|
||||
|
||||
const hooks = {
|
||||
type: 'local-jsx',
|
||||
name: 'hooks',
|
||||
description: 'View hook configurations for tool events',
|
||||
immediate: true,
|
||||
load: () => import('./hooks.js'),
|
||||
} satisfies Command
|
||||
|
||||
export default hooks
|
||||
646
src/commands/ide/ide.tsx
Normal file
646
src/commands/ide/ide.tsx
Normal file
File diff suppressed because one or more lines are too long
11
src/commands/ide/index.ts
Normal file
11
src/commands/ide/index.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import type { Command } from '../../commands.js'
|
||||
|
||||
const ide = {
|
||||
type: 'local-jsx',
|
||||
name: 'ide',
|
||||
description: 'Manage IDE integrations and show status',
|
||||
argumentHint: '[open]',
|
||||
load: () => import('./ide.js'),
|
||||
} satisfies Command
|
||||
|
||||
export default ide
|
||||
262
src/commands/init-verifiers.ts
Normal file
262
src/commands/init-verifiers.ts
Normal file
@@ -0,0 +1,262 @@
|
||||
import type { Command } from '../commands.js'
|
||||
|
||||
const command = {
|
||||
type: 'prompt',
|
||||
name: 'init-verifiers',
|
||||
description:
|
||||
'Create verifier skill(s) for automated verification of code changes',
|
||||
contentLength: 0, // Dynamic content
|
||||
progressMessage: 'analyzing your project and creating verifier skills',
|
||||
source: 'builtin',
|
||||
async getPromptForCommand() {
|
||||
return [
|
||||
{
|
||||
type: 'text',
|
||||
text: `Use the TodoWrite tool to track your progress through this multi-step task.
|
||||
|
||||
## Goal
|
||||
|
||||
Create one or more verifier skills that can be used by the Verify agent to automatically verify code changes in this project or folder. You may create multiple verifiers if the project has different verification needs (e.g., both web UI and API endpoints).
|
||||
|
||||
**Do NOT create verifiers for unit tests or typechecking.** Those are already handled by the standard build/test workflow and don't need dedicated verifier skills. Focus on functional verification: web UI (Playwright), CLI (Tmux), and API (HTTP) verifiers.
|
||||
|
||||
## Phase 1: Auto-Detection
|
||||
|
||||
Analyze the project to detect what's in different subdirectories. The project may contain multiple sub-projects or areas that need different verification approaches (e.g., a web frontend, an API backend, and shared libraries all in one repo).
|
||||
|
||||
1. **Scan top-level directories** to identify distinct project areas:
|
||||
- Look for separate package.json, Cargo.toml, pyproject.toml, go.mod in subdirectories
|
||||
- Identify distinct application types in different folders
|
||||
|
||||
2. **For each area, detect:**
|
||||
|
||||
a. **Project type and stack**
|
||||
- Primary language(s) and frameworks
|
||||
- Package managers (npm, yarn, pnpm, pip, cargo, etc.)
|
||||
|
||||
b. **Application type**
|
||||
- Web app (React, Next.js, Vue, etc.) → suggest Playwright-based verifier
|
||||
- CLI tool → suggest Tmux-based verifier
|
||||
- API service (Express, FastAPI, etc.) → suggest HTTP-based verifier
|
||||
|
||||
c. **Existing verification tools**
|
||||
- Test frameworks (Jest, Vitest, pytest, etc.)
|
||||
- E2E tools (Playwright, Cypress, etc.)
|
||||
- Dev server scripts in package.json
|
||||
|
||||
d. **Dev server configuration**
|
||||
- How to start the dev server
|
||||
- What URL it runs on
|
||||
- What text indicates it's ready
|
||||
|
||||
3. **Installed verification packages** (for web apps)
|
||||
- Check if Playwright is installed (look in package.json dependencies/devDependencies)
|
||||
- Check MCP configuration (.mcp.json) for browser automation tools:
|
||||
- Playwright MCP server
|
||||
- Chrome DevTools MCP server
|
||||
- Claude Chrome Extension MCP (browser-use via Claude's Chrome extension)
|
||||
- For Python projects, check for playwright, pytest-playwright
|
||||
|
||||
## Phase 2: Verification Tool Setup
|
||||
|
||||
Based on what was detected in Phase 1, help the user set up appropriate verification tools.
|
||||
|
||||
### For Web Applications
|
||||
|
||||
1. **If browser automation tools are already installed/configured**, ask the user which one they want to use:
|
||||
- Use AskUserQuestion to present the detected options
|
||||
- Example: "I found Playwright and Chrome DevTools MCP configured. Which would you like to use for verification?"
|
||||
|
||||
2. **If NO browser automation tools are detected**, ask if they want to install/configure one:
|
||||
- Use AskUserQuestion: "No browser automation tools detected. Would you like to set one up for UI verification?"
|
||||
- Options to offer:
|
||||
- **Playwright** (Recommended) - Full browser automation library, works headless, great for CI
|
||||
- **Chrome DevTools MCP** - Uses Chrome DevTools Protocol via MCP
|
||||
- **Claude Chrome Extension** - Uses the Claude Chrome extension for browser interaction (requires the extension installed in Chrome)
|
||||
- **None** - Skip browser automation (will use basic HTTP checks only)
|
||||
|
||||
3. **If user chooses to install Playwright**, run the appropriate command based on package manager:
|
||||
- For npm: \`npm install -D @playwright/test && npx playwright install\`
|
||||
- For yarn: \`yarn add -D @playwright/test && yarn playwright install\`
|
||||
- For pnpm: \`pnpm add -D @playwright/test && pnpm exec playwright install\`
|
||||
- For bun: \`bun add -D @playwright/test && bun playwright install\`
|
||||
|
||||
4. **If user chooses Chrome DevTools MCP or Claude Chrome Extension**:
|
||||
- These require MCP server configuration rather than package installation
|
||||
- Ask if they want you to add the MCP server configuration to .mcp.json
|
||||
- For Claude Chrome Extension, inform them they need the extension installed from the Chrome Web Store
|
||||
|
||||
5. **MCP Server Setup** (if applicable):
|
||||
- If user selected an MCP-based option, configure the appropriate entry in .mcp.json
|
||||
- Update the verifier skill's allowed-tools to use the appropriate mcp__* tools
|
||||
|
||||
### For CLI Tools
|
||||
|
||||
1. Check if asciinema is available (run \`which asciinema\`)
|
||||
2. If not available, inform the user that asciinema can help record verification sessions but is optional
|
||||
3. Tmux is typically system-installed, just verify it's available
|
||||
|
||||
### For API Services
|
||||
|
||||
1. Check if HTTP testing tools are available:
|
||||
- curl (usually system-installed)
|
||||
- httpie (\`http\` command)
|
||||
2. No installation typically needed
|
||||
|
||||
## Phase 3: Interactive Q&A
|
||||
|
||||
Based on the areas detected in Phase 1, you may need to create multiple verifiers. For each distinct area, use the AskUserQuestion tool to confirm:
|
||||
|
||||
1. **Verifier name** - Based on detection, suggest a name but let user choose:
|
||||
|
||||
If there is only ONE project area, use the simple format:
|
||||
- "verifier-playwright" for web UI testing
|
||||
- "verifier-cli" for CLI/terminal testing
|
||||
- "verifier-api" for HTTP API testing
|
||||
|
||||
If there are MULTIPLE project areas, use the format \`verifier-<project>-<type>\`:
|
||||
- "verifier-frontend-playwright" for the frontend web UI
|
||||
- "verifier-backend-api" for the backend API
|
||||
- "verifier-admin-playwright" for an admin dashboard
|
||||
|
||||
The \`<project>\` portion should be a short identifier for the subdirectory or project area (e.g., the folder name or package name).
|
||||
|
||||
Custom names are allowed but MUST include "verifier" in the name — the Verify agent discovers skills by looking for "verifier" in the folder name.
|
||||
|
||||
2. **Project-specific questions** based on type:
|
||||
|
||||
For web apps (playwright):
|
||||
- Dev server command (e.g., "npm run dev")
|
||||
- Dev server URL (e.g., "http://localhost:3000")
|
||||
- Ready signal (text that appears when server is ready)
|
||||
|
||||
For CLI tools:
|
||||
- Entry point command (e.g., "node ./cli.js" or "./target/debug/myapp")
|
||||
- Whether to record with asciinema
|
||||
|
||||
For APIs:
|
||||
- API server command
|
||||
- Base URL
|
||||
|
||||
3. **Authentication & Login** (for web apps and APIs):
|
||||
|
||||
Use AskUserQuestion to ask: "Does your app require authentication/login to access the pages or endpoints being verified?"
|
||||
- **No authentication needed** - App is publicly accessible, no login required
|
||||
- **Yes, login required** - App requires authentication before verification can proceed
|
||||
- **Some pages require auth** - Mix of public and authenticated routes
|
||||
|
||||
If the user selects login required (or partial), ask follow-up questions:
|
||||
- **Login method**: How does a user log in?
|
||||
- Form-based login (username/password on a login page)
|
||||
- API token/key (passed as header or query param)
|
||||
- OAuth/SSO (redirect-based flow)
|
||||
- Other (let user describe)
|
||||
- **Test credentials**: What credentials should the verifier use?
|
||||
- Ask for the login URL (e.g., "/login", "http://localhost:3000/auth")
|
||||
- Ask for test username/email and password, or API key
|
||||
- Note: Suggest the user use environment variables for secrets (e.g., \`TEST_USER\`, \`TEST_PASSWORD\`) rather than hardcoding
|
||||
- **Post-login indicator**: How to confirm login succeeded?
|
||||
- URL redirect (e.g., redirects to "/dashboard")
|
||||
- Element appears (e.g., "Welcome" text, user avatar)
|
||||
- Cookie/token is set
|
||||
|
||||
## Phase 4: Generate Verifier Skill
|
||||
|
||||
**All verifier skills are created in the project root's \`.claude/skills/\` directory.** This ensures they are automatically loaded when Claude runs in the project.
|
||||
|
||||
Write the skill file to \`.claude/skills/<verifier-name>/SKILL.md\`.
|
||||
|
||||
### Skill Template Structure
|
||||
|
||||
\`\`\`markdown
|
||||
---
|
||||
name: <verifier-name>
|
||||
description: <description based on type>
|
||||
allowed-tools:
|
||||
# Tools appropriate for the verifier type
|
||||
---
|
||||
|
||||
# <Verifier Title>
|
||||
|
||||
You are a verification executor. You receive a verification plan and execute it EXACTLY as written.
|
||||
|
||||
## Project Context
|
||||
<Project-specific details from detection>
|
||||
|
||||
## Setup Instructions
|
||||
<How to start any required services>
|
||||
|
||||
## Authentication
|
||||
<If auth is required, include step-by-step login instructions here>
|
||||
<Include login URL, credential env vars, and post-login verification>
|
||||
<If no auth needed, omit this section>
|
||||
|
||||
## Reporting
|
||||
|
||||
Report PASS or FAIL for each step using the format specified in the verification plan.
|
||||
|
||||
## Cleanup
|
||||
|
||||
After verification:
|
||||
1. Stop any dev servers started
|
||||
2. Close any browser sessions
|
||||
3. Report final summary
|
||||
|
||||
## Self-Update
|
||||
|
||||
If verification fails because this skill's instructions are outdated (dev server command/port/ready-signal changed, etc.) — not because the feature under test is broken — or if the user corrects you mid-run, use AskUserQuestion to confirm and then Edit this SKILL.md with a minimal targeted fix.
|
||||
\`\`\`
|
||||
|
||||
### Allowed Tools by Type
|
||||
|
||||
**verifier-playwright**:
|
||||
\`\`\`yaml
|
||||
allowed-tools:
|
||||
- Bash(npm:*)
|
||||
- Bash(yarn:*)
|
||||
- Bash(pnpm:*)
|
||||
- Bash(bun:*)
|
||||
- mcp__playwright__*
|
||||
- Read
|
||||
- Glob
|
||||
- Grep
|
||||
\`\`\`
|
||||
|
||||
**verifier-cli**:
|
||||
\`\`\`yaml
|
||||
allowed-tools:
|
||||
- Tmux
|
||||
- Bash(asciinema:*)
|
||||
- Read
|
||||
- Glob
|
||||
- Grep
|
||||
\`\`\`
|
||||
|
||||
**verifier-api**:
|
||||
\`\`\`yaml
|
||||
allowed-tools:
|
||||
- Bash(curl:*)
|
||||
- Bash(http:*)
|
||||
- Bash(npm:*)
|
||||
- Bash(yarn:*)
|
||||
- Read
|
||||
- Glob
|
||||
- Grep
|
||||
\`\`\`
|
||||
|
||||
|
||||
## Phase 5: Confirm Creation
|
||||
|
||||
After writing the skill file(s), inform the user:
|
||||
1. Where each skill was created (always in \`.claude/skills/\`)
|
||||
2. How the Verify agent will discover them — the folder name must contain "verifier" (case-insensitive) for automatic discovery
|
||||
3. That they can edit the skills to customize them
|
||||
4. That they can run /init-verifiers again to add more verifiers for other areas
|
||||
5. That the verifier will offer to self-update if it detects its own instructions are outdated (wrong dev server command, changed ready signal, etc.)
|
||||
`,
|
||||
},
|
||||
]
|
||||
},
|
||||
} satisfies Command
|
||||
|
||||
export default command
|
||||
256
src/commands/init.ts
Normal file
256
src/commands/init.ts
Normal file
@@ -0,0 +1,256 @@
|
||||
import { feature } from 'bun:bundle'
|
||||
import type { Command } from '../commands.js'
|
||||
import { maybeMarkProjectOnboardingComplete } from '../projectOnboardingState.js'
|
||||
import { isEnvTruthy } from '../utils/envUtils.js'
|
||||
|
||||
const OLD_INIT_PROMPT = `Please analyze this codebase and create a CLAUDE.md file, which will be given to future instances of Claude Code to operate in this repository.
|
||||
|
||||
What to add:
|
||||
1. Commands that will be commonly used, such as how to build, lint, and run tests. Include the necessary commands to develop in this codebase, such as how to run a single test.
|
||||
2. High-level code architecture and structure so that future instances can be productive more quickly. Focus on the "big picture" architecture that requires reading multiple files to understand.
|
||||
|
||||
Usage notes:
|
||||
- If there's already a CLAUDE.md, suggest improvements to it.
|
||||
- When you make the initial CLAUDE.md, do not repeat yourself and do not include obvious instructions like "Provide helpful error messages to users", "Write unit tests for all new utilities", "Never include sensitive information (API keys, tokens) in code or commits".
|
||||
- Avoid listing every component or file structure that can be easily discovered.
|
||||
- Don't include generic development practices.
|
||||
- If there are Cursor rules (in .cursor/rules/ or .cursorrules) or Copilot rules (in .github/copilot-instructions.md), make sure to include the important parts.
|
||||
- If there is a README.md, make sure to include the important parts.
|
||||
- Do not make up information such as "Common Development Tasks", "Tips for Development", "Support and Documentation" unless this is expressly included in other files that you read.
|
||||
- Be sure to prefix the file with the following text:
|
||||
|
||||
\`\`\`
|
||||
# CLAUDE.md
|
||||
|
||||
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||
\`\`\``
|
||||
|
||||
const NEW_INIT_PROMPT = `Set up a minimal CLAUDE.md (and optionally skills and hooks) for this repo. CLAUDE.md is loaded into every Claude Code session, so it must be concise — only include what Claude would get wrong without it.
|
||||
|
||||
## Phase 1: Ask what to set up
|
||||
|
||||
Use AskUserQuestion to find out what the user wants:
|
||||
|
||||
- "Which CLAUDE.md files should /init set up?"
|
||||
Options: "Project CLAUDE.md" | "Personal CLAUDE.local.md" | "Both project + personal"
|
||||
Description for project: "Team-shared instructions checked into source control — architecture, coding standards, common workflows."
|
||||
Description for personal: "Your private preferences for this project (gitignored, not shared) — your role, sandbox URLs, preferred test data, workflow quirks."
|
||||
|
||||
- "Also set up skills and hooks?"
|
||||
Options: "Skills + hooks" | "Skills only" | "Hooks only" | "Neither, just CLAUDE.md"
|
||||
Description for skills: "On-demand capabilities you or Claude invoke with \`/skill-name\` — good for repeatable workflows and reference knowledge."
|
||||
Description for hooks: "Deterministic shell commands that run on tool events (e.g., format after every edit). Claude can't skip them."
|
||||
|
||||
## Phase 2: Explore the codebase
|
||||
|
||||
Launch a subagent to survey the codebase, and ask it to read key files to understand the project: manifest files (package.json, Cargo.toml, pyproject.toml, go.mod, pom.xml, etc.), README, Makefile/build configs, CI config, existing CLAUDE.md, .claude/rules/, AGENTS.md, .cursor/rules or .cursorrules, .github/copilot-instructions.md, .windsurfrules, .clinerules, .mcp.json.
|
||||
|
||||
Detect:
|
||||
- Build, test, and lint commands (especially non-standard ones)
|
||||
- Languages, frameworks, and package manager
|
||||
- Project structure (monorepo with workspaces, multi-module, or single project)
|
||||
- Code style rules that differ from language defaults
|
||||
- Non-obvious gotchas, required env vars, or workflow quirks
|
||||
- Existing .claude/skills/ and .claude/rules/ directories
|
||||
- Formatter configuration (prettier, biome, ruff, black, gofmt, rustfmt, or a unified format script like \`npm run format\` / \`make fmt\`)
|
||||
- Git worktree usage: run \`git worktree list\` to check if this repo has multiple worktrees (only relevant if the user wants a personal CLAUDE.local.md)
|
||||
|
||||
Note what you could NOT figure out from code alone — these become interview questions.
|
||||
|
||||
## Phase 3: Fill in the gaps
|
||||
|
||||
Use AskUserQuestion to gather what you still need to write good CLAUDE.md files and skills. Ask only things the code can't answer.
|
||||
|
||||
If the user chose project CLAUDE.md or both: ask about codebase practices — non-obvious commands, gotchas, branch/PR conventions, required env setup, testing quirks. Skip things already in README or obvious from manifest files. Do not mark any options as "recommended" — this is about how their team works, not best practices.
|
||||
|
||||
If the user chose personal CLAUDE.local.md or both: ask about them, not the codebase. Do not mark any options as "recommended" — this is about their personal preferences, not best practices. Examples of questions:
|
||||
- What's their role on the team? (e.g., "backend engineer", "data scientist", "new hire onboarding")
|
||||
- How familiar are they with this codebase and its languages/frameworks? (so Claude can calibrate explanation depth)
|
||||
- Do they have personal sandbox URLs, test accounts, API key paths, or local setup details Claude should know?
|
||||
- Only if Phase 2 found multiple git worktrees: ask whether their worktrees are nested inside the main repo (e.g., \`.claude/worktrees/<name>/\`) or siblings/external (e.g., \`../myrepo-feature/\`). If nested, the upward file walk finds the main repo's CLAUDE.local.md automatically — no special handling needed. If sibling/external, the personal content should live in a home-directory file (e.g., \`~/.claude/<project-name>-instructions.md\`) and each worktree gets a one-line CLAUDE.local.md stub that imports it: \`@~/.claude/<project-name>-instructions.md\`. Never put this import in the project CLAUDE.md — that would check a personal reference into the team-shared file.
|
||||
- Any communication preferences? (e.g., "be terse", "always explain tradeoffs", "don't summarize at the end")
|
||||
|
||||
**Synthesize a proposal from Phase 2 findings** — e.g., format-on-edit if a formatter exists, a \`/verify\` skill if tests exist, a CLAUDE.md note for anything from the gap-fill answers that's a guideline rather than a workflow. For each, pick the artifact type that fits, **constrained by the Phase 1 skills+hooks choice**:
|
||||
|
||||
- **Hook** (stricter) — deterministic shell command on a tool event; Claude can't skip it. Fits mechanical, fast, per-edit steps: formatting, linting, running a quick test on the changed file.
|
||||
- **Skill** (on-demand) — you or Claude invoke \`/skill-name\` when you want it. Fits workflows that don't belong on every edit: deep verification, session reports, deploys.
|
||||
- **CLAUDE.md note** (looser) — influences Claude's behavior but not enforced. Fits communication/thinking preferences: "plan before coding", "be terse", "explain tradeoffs".
|
||||
|
||||
**Respect Phase 1's skills+hooks choice as a hard filter**: if the user picked "Skills only", downgrade any hook you'd suggest to a skill or a CLAUDE.md note. If "Hooks only", downgrade skills to hooks (where mechanically possible) or notes. If "Neither", everything becomes a CLAUDE.md note. Never propose an artifact type the user didn't opt into.
|
||||
|
||||
**Show the proposal via AskUserQuestion's \`preview\` field, not as a separate text message** — the dialog overlays your output, so preceding text is hidden. The \`preview\` field renders markdown in a side-panel (like plan mode); the \`question\` field is plain-text-only. Structure it as:
|
||||
|
||||
- \`question\`: short and plain, e.g. "Does this proposal look right?"
|
||||
- Each option gets a \`preview\` with the full proposal as markdown. The "Looks good — proceed" option's preview shows everything; per-item-drop options' previews show what remains after that drop.
|
||||
- **Keep previews compact — the preview box truncates with no scrolling.** One line per item, no blank lines between items, no header. Example preview content:
|
||||
|
||||
• **Format-on-edit hook** (automatic) — \`ruff format <file>\` via PostToolUse
|
||||
• **/verify skill** (on-demand) — \`make lint && make typecheck && make test\`
|
||||
• **CLAUDE.md note** (guideline) — "run lint/typecheck/test before marking done"
|
||||
|
||||
- Option labels stay short ("Looks good", "Drop the hook", "Drop the skill") — the tool auto-adds an "Other" free-text option, so don't add your own catch-all.
|
||||
|
||||
**Build the preference queue** from the accepted proposal. Each entry: {type: hook|skill|note, description, target file, any Phase-2-sourced details like the actual test/format command}. Phases 4-7 consume this queue.
|
||||
|
||||
## Phase 4: Write CLAUDE.md (if user chose project or both)
|
||||
|
||||
Write a minimal CLAUDE.md at the project root. Every line must pass this test: "Would removing this cause Claude to make mistakes?" If no, cut it.
|
||||
|
||||
**Consume \`note\` entries from the Phase 3 preference queue whose target is CLAUDE.md** (team-level notes) — add each as a concise line in the most relevant section. These are the behaviors the user wants Claude to follow but didn't need guaranteed (e.g., "propose a plan before implementing", "explain the tradeoffs when refactoring"). Leave personal-targeted notes for Phase 5.
|
||||
|
||||
Include:
|
||||
- Build/test/lint commands Claude can't guess (non-standard scripts, flags, or sequences)
|
||||
- Code style rules that DIFFER from language defaults (e.g., "prefer type over interface")
|
||||
- Testing instructions and quirks (e.g., "run single test with: pytest -k 'test_name'")
|
||||
- Repo etiquette (branch naming, PR conventions, commit style)
|
||||
- Required env vars or setup steps
|
||||
- Non-obvious gotchas or architectural decisions
|
||||
- Important parts from existing AI coding tool configs if they exist (AGENTS.md, .cursor/rules, .cursorrules, .github/copilot-instructions.md, .windsurfrules, .clinerules)
|
||||
|
||||
Exclude:
|
||||
- File-by-file structure or component lists (Claude can discover these by reading the codebase)
|
||||
- Standard language conventions Claude already knows
|
||||
- Generic advice ("write clean code", "handle errors")
|
||||
- Detailed API docs or long references — use \`@path/to/import\` syntax instead (e.g., \`@docs/api-reference.md\`) to inline content on demand without bloating CLAUDE.md
|
||||
- Information that changes frequently — reference the source with \`@path/to/import\` so Claude always reads the current version
|
||||
- Long tutorials or walkthroughs (move to a separate file and reference with \`@path/to/import\`, or put in a skill)
|
||||
- Commands obvious from manifest files (e.g., standard "npm test", "cargo test", "pytest")
|
||||
|
||||
Be specific: "Use 2-space indentation in TypeScript" is better than "Format code properly."
|
||||
|
||||
Do not repeat yourself and do not make up sections like "Common Development Tasks" or "Tips for Development" — only include information expressly found in files you read.
|
||||
|
||||
Prefix the file with:
|
||||
|
||||
\`\`\`
|
||||
# CLAUDE.md
|
||||
|
||||
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||
\`\`\`
|
||||
|
||||
If CLAUDE.md already exists: read it, propose specific changes as diffs, and explain why each change improves it. Do not silently overwrite.
|
||||
|
||||
For projects with multiple concerns, suggest organizing instructions into \`.claude/rules/\` as separate focused files (e.g., \`code-style.md\`, \`testing.md\`, \`security.md\`). These are loaded automatically alongside CLAUDE.md and can be scoped to specific file paths using \`paths\` frontmatter.
|
||||
|
||||
For projects with distinct subdirectories (monorepos, multi-module projects, etc.): mention that subdirectory CLAUDE.md files can be added for module-specific instructions (they're loaded automatically when Claude works in those directories). Offer to create them if the user wants.
|
||||
|
||||
## Phase 5: Write CLAUDE.local.md (if user chose personal or both)
|
||||
|
||||
Write a minimal CLAUDE.local.md at the project root. This file is automatically loaded alongside CLAUDE.md. After creating it, add \`CLAUDE.local.md\` to the project's .gitignore so it stays private.
|
||||
|
||||
**Consume \`note\` entries from the Phase 3 preference queue whose target is CLAUDE.local.md** (personal-level notes) — add each as a concise line. If the user chose personal-only in Phase 1, this is the sole consumer of note entries.
|
||||
|
||||
Include:
|
||||
- The user's role and familiarity with the codebase (so Claude can calibrate explanations)
|
||||
- Personal sandbox URLs, test accounts, or local setup details
|
||||
- Personal workflow or communication preferences
|
||||
|
||||
Keep it short — only include what would make Claude's responses noticeably better for this user.
|
||||
|
||||
If Phase 2 found multiple git worktrees and the user confirmed they use sibling/external worktrees (not nested inside the main repo): the upward file walk won't find a single CLAUDE.local.md from all worktrees. Write the actual personal content to \`~/.claude/<project-name>-instructions.md\` and make CLAUDE.local.md a one-line stub that imports it: \`@~/.claude/<project-name>-instructions.md\`. The user can copy this one-line stub to each sibling worktree. Never put this import in the project CLAUDE.md. If worktrees are nested inside the main repo (e.g., \`.claude/worktrees/\`), no special handling is needed — the main repo's CLAUDE.local.md is found automatically.
|
||||
|
||||
If CLAUDE.local.md already exists: read it, propose specific additions, and do not silently overwrite.
|
||||
|
||||
## Phase 6: Suggest and create skills (if user chose "Skills + hooks" or "Skills only")
|
||||
|
||||
Skills add capabilities Claude can use on demand without bloating every session.
|
||||
|
||||
**First, consume \`skill\` entries from the Phase 3 preference queue.** Each queued skill preference becomes a SKILL.md tailored to what the user described. For each:
|
||||
- Name it from the preference (e.g., "verify-deep", "session-report", "deploy-sandbox")
|
||||
- Write the body using the user's own words from the interview plus whatever Phase 2 found (test commands, report format, deploy target). If the preference maps to an existing bundled skill (e.g., \`/verify\`), write a project skill that adds the user's specific constraints on top — tell the user the bundled one still exists and theirs is additive.
|
||||
- Ask a quick follow-up if the preference is underspecified (e.g., "which test command should verify-deep run?")
|
||||
|
||||
**Then suggest additional skills** beyond the queue when you find:
|
||||
- Reference knowledge for specific tasks (conventions, patterns, style guides for a subsystem)
|
||||
- Repeatable workflows the user would want to trigger directly (deploy, fix an issue, release process, verify changes)
|
||||
|
||||
For each suggested skill, provide: name, one-line purpose, and why it fits this repo.
|
||||
|
||||
If \`.claude/skills/\` already exists with skills, review them first. Do not overwrite existing skills — only propose new ones that complement what is already there.
|
||||
|
||||
Create each skill at \`.claude/skills/<skill-name>/SKILL.md\`:
|
||||
|
||||
\`\`\`yaml
|
||||
---
|
||||
name: <skill-name>
|
||||
description: <what the skill does and when to use it>
|
||||
---
|
||||
|
||||
<Instructions for Claude>
|
||||
\`\`\`
|
||||
|
||||
Both the user (\`/<skill-name>\`) and Claude can invoke skills by default. For workflows with side effects (e.g., \`/deploy\`, \`/fix-issue 123\`), add \`disable-model-invocation: true\` so only the user can trigger it, and use \`$ARGUMENTS\` to accept input.
|
||||
|
||||
## Phase 7: Suggest additional optimizations
|
||||
|
||||
Tell the user you're going to suggest a few additional optimizations now that CLAUDE.md and skills (if chosen) are in place.
|
||||
|
||||
Check the environment and ask about each gap you find (use AskUserQuestion):
|
||||
|
||||
- **GitHub CLI**: Run \`which gh\` (or \`where gh\` on Windows). If it's missing AND the project uses GitHub (check \`git remote -v\` for github.com), ask the user if they want to install it. Explain that the GitHub CLI lets Claude help with commits, pull requests, issues, and code review directly.
|
||||
|
||||
- **Linting**: If Phase 2 found no lint config (no .eslintrc, ruff.toml, .golangci.yml, etc. for the project's language), ask the user if they want Claude to set up linting for this codebase. Explain that linting catches issues early and gives Claude fast feedback on its own edits.
|
||||
|
||||
- **Proposal-sourced hooks** (if user chose "Skills + hooks" or "Hooks only"): Consume \`hook\` entries from the Phase 3 preference queue. If Phase 2 found a formatter and the queue has no formatting hook, offer format-on-edit as a fallback. If the user chose "Neither" or "Skills only" in Phase 1, skip this bullet entirely.
|
||||
|
||||
For each hook preference (from the queue or the formatter fallback):
|
||||
|
||||
1. Target file: default based on the Phase 1 CLAUDE.md choice — project → \`.claude/settings.json\` (team-shared, committed); personal → \`.claude/settings.local.json\`. Only ask if the user chose "both" in Phase 1 or the preference is ambiguous. Ask once for all hooks, not per-hook.
|
||||
|
||||
2. Pick the event and matcher from the preference:
|
||||
- "after every edit" → \`PostToolUse\` with matcher \`Write|Edit\`
|
||||
- "when Claude finishes" / "before I review" → \`Stop\` event (fires at the end of every turn — including read-only ones)
|
||||
- "before running bash" → \`PreToolUse\` with matcher \`Bash\`
|
||||
- "before committing" (literal git-commit gate) → **not a hooks.json hook.** Matchers can't filter Bash by command content, so there's no way to target only \`git commit\`. Route this to a git pre-commit hook (\`.git/hooks/pre-commit\`, husky, pre-commit framework) instead — offer to write one. If the user actually means "before I review and commit Claude's output", that's \`Stop\` — probe to disambiguate.
|
||||
Probe if the preference is ambiguous.
|
||||
|
||||
3. **Load the hook reference** (once per \`/init\` run, before the first hook): invoke the Skill tool with \`skill: 'update-config'\` and args starting with \`[hooks-only]\` followed by a one-line summary of what you're building — e.g., \`[hooks-only] Constructing a PostToolUse/Write|Edit format hook for .claude/settings.json using ruff\`. This loads the hooks schema and verification flow into context. Subsequent hooks reuse it — don't re-invoke.
|
||||
|
||||
4. Follow the skill's **"Constructing a Hook"** flow: dedup check → construct for THIS project → pipe-test raw → wrap → write JSON → \`jq -e\` validate → live-proof (for \`Pre|PostToolUse\` on triggerable matchers) → cleanup → handoff. Target file and event/matcher come from steps 1–2 above.
|
||||
|
||||
Act on each "yes" before moving on.
|
||||
|
||||
## Phase 8: Summary and next steps
|
||||
|
||||
Recap what was set up — which files were written and the key points included in each. Remind the user these files are a starting point: they should review and tweak them, and can run \`/init\` again anytime to re-scan.
|
||||
|
||||
Then tell the user that you'll be introducing a few more suggestions for optimizing their codebase and Claude Code setup based on what you found. Present these as a single, well-formatted to-do list where every item is relevant to this repo. Put the most impactful items first.
|
||||
|
||||
When building the list, work through these checks and include only what applies:
|
||||
- If frontend code was detected (React, Vue, Svelte, etc.): \`/plugin install frontend-design@claude-plugins-official\` gives Claude design principles and component patterns so it produces polished UI; \`/plugin install playwright@claude-plugins-official\` lets Claude launch a real browser, screenshot what it built, and fix visual bugs itself.
|
||||
- If you found gaps in Phase 7 (missing GitHub CLI, missing linting) and the user said no: list them here with a one-line reason why each helps.
|
||||
- If tests are missing or sparse: suggest setting up a test framework so Claude can verify its own changes.
|
||||
- To help you create skills and optimize existing skills using evals, Claude Code has an official skill-creator plugin you can install. Install it with \`/plugin install skill-creator@claude-plugins-official\`, then run \`/skill-creator <skill-name>\` to create new skills or refine any existing skill. (Always include this one.)
|
||||
- Browse official plugins with \`/plugin\` — these bundle skills, agents, hooks, and MCP servers that you may find helpful. You can also create your own custom plugins to share them with others. (Always include this one.)`
|
||||
|
||||
const command = {
|
||||
type: 'prompt',
|
||||
name: 'init',
|
||||
get description() {
|
||||
return feature('NEW_INIT') &&
|
||||
(process.env.USER_TYPE === 'ant' ||
|
||||
isEnvTruthy(process.env.CLAUDE_CODE_NEW_INIT))
|
||||
? 'Initialize new CLAUDE.md file(s) and optional skills/hooks with codebase documentation'
|
||||
: 'Initialize a new CLAUDE.md file with codebase documentation'
|
||||
},
|
||||
contentLength: 0, // Dynamic content
|
||||
progressMessage: 'analyzing your codebase',
|
||||
source: 'builtin',
|
||||
async getPromptForCommand() {
|
||||
maybeMarkProjectOnboardingComplete()
|
||||
|
||||
return [
|
||||
{
|
||||
type: 'text',
|
||||
text:
|
||||
feature('NEW_INIT') &&
|
||||
(process.env.USER_TYPE === 'ant' ||
|
||||
isEnvTruthy(process.env.CLAUDE_CODE_NEW_INIT))
|
||||
? NEW_INIT_PROMPT
|
||||
: OLD_INIT_PROMPT,
|
||||
},
|
||||
]
|
||||
},
|
||||
} satisfies Command
|
||||
|
||||
export default command
|
||||
3200
src/commands/insights.ts
Normal file
3200
src/commands/insights.ts
Normal file
File diff suppressed because it is too large
Load Diff
231
src/commands/install-github-app/ApiKeyStep.tsx
Normal file
231
src/commands/install-github-app/ApiKeyStep.tsx
Normal file
File diff suppressed because one or more lines are too long
190
src/commands/install-github-app/CheckExistingSecretStep.tsx
Normal file
190
src/commands/install-github-app/CheckExistingSecretStep.tsx
Normal file
File diff suppressed because one or more lines are too long
15
src/commands/install-github-app/CheckGitHubStep.tsx
Normal file
15
src/commands/install-github-app/CheckGitHubStep.tsx
Normal file
@@ -0,0 +1,15 @@
|
||||
import { c as _c } from "react/compiler-runtime";
|
||||
import React from 'react';
|
||||
import { Text } from '../../ink.js';
|
||||
export function CheckGitHubStep() {
|
||||
const $ = _c(1);
|
||||
let t0;
|
||||
if ($[0] === Symbol.for("react.memo_cache_sentinel")) {
|
||||
t0 = <Text>Checking GitHub CLI installation…</Text>;
|
||||
$[0] = t0;
|
||||
} else {
|
||||
t0 = $[0];
|
||||
}
|
||||
return t0;
|
||||
}
|
||||
//# sourceMappingURL=data:application/json;charset=utf-8;base64,eyJ2ZXJzaW9uIjozLCJuYW1lcyI6WyJSZWFjdCIsIlRleHQiLCJDaGVja0dpdEh1YlN0ZXAiLCIkIiwiX2MiLCJ0MCIsIlN5bWJvbCIsImZvciJdLCJzb3VyY2VzIjpbIkNoZWNrR2l0SHViU3RlcC50c3giXSwic291cmNlc0NvbnRlbnQiOlsiaW1wb3J0IFJlYWN0IGZyb20gJ3JlYWN0J1xuaW1wb3J0IHsgVGV4dCB9IGZyb20gJy4uLy4uL2luay5qcydcblxuZXhwb3J0IGZ1bmN0aW9uIENoZWNrR2l0SHViU3RlcCgpIHtcbiAgcmV0dXJuIDxUZXh0PkNoZWNraW5nIEdpdEh1YiBDTEkgaW5zdGFsbGF0aW9u4oCmPC9UZXh0PlxufVxuIl0sIm1hcHBpbmdzIjoiO0FBQUEsT0FBT0EsS0FBSyxNQUFNLE9BQU87QUFDekIsU0FBU0MsSUFBSSxRQUFRLGNBQWM7QUFFbkMsT0FBTyxTQUFBQyxnQkFBQTtFQUFBLE1BQUFDLENBQUEsR0FBQUMsRUFBQTtFQUFBLElBQUFDLEVBQUE7RUFBQSxJQUFBRixDQUFBLFFBQUFHLE1BQUEsQ0FBQUMsR0FBQTtJQUNFRixFQUFBLElBQUMsSUFBSSxDQUFDLGlDQUFpQyxFQUF0QyxJQUFJLENBQXlDO0lBQUFGLENBQUEsTUFBQUUsRUFBQTtFQUFBO0lBQUFBLEVBQUEsR0FBQUYsQ0FBQTtFQUFBO0VBQUEsT0FBOUNFLEVBQThDO0FBQUEiLCJpZ25vcmVMaXN0IjpbXX0=
|
||||
211
src/commands/install-github-app/ChooseRepoStep.tsx
Normal file
211
src/commands/install-github-app/ChooseRepoStep.tsx
Normal file
File diff suppressed because one or more lines are too long
65
src/commands/install-github-app/CreatingStep.tsx
Normal file
65
src/commands/install-github-app/CreatingStep.tsx
Normal file
File diff suppressed because one or more lines are too long
85
src/commands/install-github-app/ErrorStep.tsx
Normal file
85
src/commands/install-github-app/ErrorStep.tsx
Normal file
File diff suppressed because one or more lines are too long
103
src/commands/install-github-app/ExistingWorkflowStep.tsx
Normal file
103
src/commands/install-github-app/ExistingWorkflowStep.tsx
Normal file
File diff suppressed because one or more lines are too long
94
src/commands/install-github-app/InstallAppStep.tsx
Normal file
94
src/commands/install-github-app/InstallAppStep.tsx
Normal file
File diff suppressed because one or more lines are too long
276
src/commands/install-github-app/OAuthFlowStep.tsx
Normal file
276
src/commands/install-github-app/OAuthFlowStep.tsx
Normal file
File diff suppressed because one or more lines are too long
96
src/commands/install-github-app/SuccessStep.tsx
Normal file
96
src/commands/install-github-app/SuccessStep.tsx
Normal file
File diff suppressed because one or more lines are too long
73
src/commands/install-github-app/WarningsStep.tsx
Normal file
73
src/commands/install-github-app/WarningsStep.tsx
Normal file
File diff suppressed because one or more lines are too long
13
src/commands/install-github-app/index.ts
Normal file
13
src/commands/install-github-app/index.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import type { Command } from '../../commands.js'
|
||||
import { isEnvTruthy } from '../../utils/envUtils.js'
|
||||
|
||||
const installGitHubApp = {
|
||||
type: 'local-jsx',
|
||||
name: 'install-github-app',
|
||||
description: 'Set up Claude GitHub Actions for a repository',
|
||||
availability: ['claude-ai', 'console'],
|
||||
isEnabled: () => !isEnvTruthy(process.env.DISABLE_INSTALL_GITHUB_APP_COMMAND),
|
||||
load: () => import('./install-github-app.js'),
|
||||
} satisfies Command
|
||||
|
||||
export default installGitHubApp
|
||||
587
src/commands/install-github-app/install-github-app.tsx
Normal file
587
src/commands/install-github-app/install-github-app.tsx
Normal file
File diff suppressed because one or more lines are too long
325
src/commands/install-github-app/setupGitHubActions.ts
Normal file
325
src/commands/install-github-app/setupGitHubActions.ts
Normal file
@@ -0,0 +1,325 @@
|
||||
import {
|
||||
type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
||||
logEvent,
|
||||
} from 'src/services/analytics/index.js'
|
||||
import { saveGlobalConfig } from 'src/utils/config.js'
|
||||
import {
|
||||
CODE_REVIEW_PLUGIN_WORKFLOW_CONTENT,
|
||||
PR_BODY,
|
||||
PR_TITLE,
|
||||
WORKFLOW_CONTENT,
|
||||
} from '../../constants/github-app.js'
|
||||
import { openBrowser } from '../../utils/browser.js'
|
||||
import { execFileNoThrow } from '../../utils/execFileNoThrow.js'
|
||||
import { logError } from '../../utils/log.js'
|
||||
import type { Workflow } from './types.js'
|
||||
|
||||
async function createWorkflowFile(
|
||||
repoName: string,
|
||||
branchName: string,
|
||||
workflowPath: string,
|
||||
workflowContent: string,
|
||||
secretName: string,
|
||||
message: string,
|
||||
context?: {
|
||||
useCurrentRepo?: boolean
|
||||
workflowExists?: boolean
|
||||
secretExists?: boolean
|
||||
},
|
||||
): Promise<void> {
|
||||
// Check if workflow file already exists
|
||||
const checkFileResult = await execFileNoThrow('gh', [
|
||||
'api',
|
||||
`repos/${repoName}/contents/${workflowPath}`,
|
||||
'--jq',
|
||||
'.sha',
|
||||
])
|
||||
|
||||
let fileSha: string | null = null
|
||||
if (checkFileResult.code === 0) {
|
||||
fileSha = checkFileResult.stdout.trim()
|
||||
}
|
||||
|
||||
let content = workflowContent
|
||||
if (secretName === 'CLAUDE_CODE_OAUTH_TOKEN') {
|
||||
// For OAuth tokens, use the claude_code_oauth_token parameter
|
||||
content = workflowContent.replace(
|
||||
/anthropic_api_key: \$\{\{ secrets\.ANTHROPIC_API_KEY \}\}/g,
|
||||
`claude_code_oauth_token: \${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}`,
|
||||
)
|
||||
} else if (secretName !== 'ANTHROPIC_API_KEY') {
|
||||
// For other custom secret names, keep using anthropic_api_key parameter
|
||||
content = workflowContent.replace(
|
||||
/anthropic_api_key: \$\{\{ secrets\.ANTHROPIC_API_KEY \}\}/g,
|
||||
`anthropic_api_key: \${{ secrets.${secretName} }}`,
|
||||
)
|
||||
}
|
||||
const base64Content = Buffer.from(content).toString('base64')
|
||||
|
||||
const apiParams = [
|
||||
'api',
|
||||
'--method',
|
||||
'PUT',
|
||||
`repos/${repoName}/contents/${workflowPath}`,
|
||||
'-f',
|
||||
`message=${fileSha ? `"Update ${message}"` : `"${message}"`}`,
|
||||
'-f',
|
||||
`content=${base64Content}`,
|
||||
'-f',
|
||||
`branch=${branchName}`,
|
||||
]
|
||||
|
||||
if (fileSha) {
|
||||
apiParams.push('-f', `sha=${fileSha}`)
|
||||
}
|
||||
|
||||
const createFileResult = await execFileNoThrow('gh', apiParams)
|
||||
if (createFileResult.code !== 0) {
|
||||
if (
|
||||
createFileResult.stderr.includes('422') &&
|
||||
createFileResult.stderr.includes('sha')
|
||||
) {
|
||||
logEvent('tengu_setup_github_actions_failed', {
|
||||
reason:
|
||||
'failed_to_create_workflow_file' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
||||
exit_code: createFileResult.code,
|
||||
...context,
|
||||
})
|
||||
throw new Error(
|
||||
`Failed to create workflow file ${workflowPath}: A Claude workflow file already exists in this repository. Please remove it first or update it manually.`,
|
||||
)
|
||||
}
|
||||
|
||||
logEvent('tengu_setup_github_actions_failed', {
|
||||
reason:
|
||||
'failed_to_create_workflow_file' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
||||
exit_code: createFileResult.code,
|
||||
...context,
|
||||
})
|
||||
|
||||
const helpText =
|
||||
'\n\nNeed help? Common issues:\n' +
|
||||
'· Permission denied → Run: gh auth refresh -h github.com -s repo,workflow\n' +
|
||||
'· Not authorized → Ensure you have admin access to the repository\n' +
|
||||
'· For manual setup → Visit: https://github.com/anthropics/claude-code-action'
|
||||
|
||||
throw new Error(
|
||||
`Failed to create workflow file ${workflowPath}: ${createFileResult.stderr}${helpText}`,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
export async function setupGitHubActions(
|
||||
repoName: string,
|
||||
apiKeyOrOAuthToken: string | null,
|
||||
secretName: string,
|
||||
updateProgress: () => void,
|
||||
skipWorkflow = false,
|
||||
selectedWorkflows: Workflow[],
|
||||
authType: 'api_key' | 'oauth_token',
|
||||
context?: {
|
||||
useCurrentRepo?: boolean
|
||||
workflowExists?: boolean
|
||||
secretExists?: boolean
|
||||
},
|
||||
) {
|
||||
try {
|
||||
logEvent('tengu_setup_github_actions_started', {
|
||||
skip_workflow: skipWorkflow,
|
||||
has_api_key: !!apiKeyOrOAuthToken,
|
||||
using_default_secret_name: secretName === 'ANTHROPIC_API_KEY',
|
||||
selected_claude_workflow: selectedWorkflows.includes('claude'),
|
||||
selected_claude_review_workflow:
|
||||
selectedWorkflows.includes('claude-review'),
|
||||
...context,
|
||||
})
|
||||
|
||||
// Check if repository exists
|
||||
const repoCheckResult = await execFileNoThrow('gh', [
|
||||
'api',
|
||||
`repos/${repoName}`,
|
||||
'--jq',
|
||||
'.id',
|
||||
])
|
||||
if (repoCheckResult.code !== 0) {
|
||||
logEvent('tengu_setup_github_actions_failed', {
|
||||
reason:
|
||||
'repo_not_found' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
||||
exit_code: repoCheckResult.code,
|
||||
...context,
|
||||
})
|
||||
throw new Error(
|
||||
`Failed to access repository ${repoName}: ${repoCheckResult.stderr}`,
|
||||
)
|
||||
}
|
||||
|
||||
// Get default branch
|
||||
const defaultBranchResult = await execFileNoThrow('gh', [
|
||||
'api',
|
||||
`repos/${repoName}`,
|
||||
'--jq',
|
||||
'.default_branch',
|
||||
])
|
||||
if (defaultBranchResult.code !== 0) {
|
||||
logEvent('tengu_setup_github_actions_failed', {
|
||||
reason:
|
||||
'failed_to_get_default_branch' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
||||
exit_code: defaultBranchResult.code,
|
||||
...context,
|
||||
})
|
||||
throw new Error(
|
||||
`Failed to get default branch: ${defaultBranchResult.stderr}`,
|
||||
)
|
||||
}
|
||||
const defaultBranch = defaultBranchResult.stdout.trim()
|
||||
|
||||
// Get SHA of default branch
|
||||
const shaResult = await execFileNoThrow('gh', [
|
||||
'api',
|
||||
`repos/${repoName}/git/ref/heads/${defaultBranch}`,
|
||||
'--jq',
|
||||
'.object.sha',
|
||||
])
|
||||
if (shaResult.code !== 0) {
|
||||
logEvent('tengu_setup_github_actions_failed', {
|
||||
reason:
|
||||
'failed_to_get_branch_sha' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
||||
exit_code: shaResult.code,
|
||||
...context,
|
||||
})
|
||||
throw new Error(`Failed to get branch SHA: ${shaResult.stderr}`)
|
||||
}
|
||||
const sha = shaResult.stdout.trim()
|
||||
|
||||
let branchName: string | null = null
|
||||
|
||||
if (!skipWorkflow) {
|
||||
updateProgress()
|
||||
// Create new branch
|
||||
branchName = `add-claude-github-actions-${Date.now()}`
|
||||
const createBranchResult = await execFileNoThrow('gh', [
|
||||
'api',
|
||||
'--method',
|
||||
'POST',
|
||||
`repos/${repoName}/git/refs`,
|
||||
'-f',
|
||||
`ref=refs/heads/${branchName}`,
|
||||
'-f',
|
||||
`sha=${sha}`,
|
||||
])
|
||||
if (createBranchResult.code !== 0) {
|
||||
logEvent('tengu_setup_github_actions_failed', {
|
||||
reason:
|
||||
'failed_to_create_branch' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
||||
exit_code: createBranchResult.code,
|
||||
...context,
|
||||
})
|
||||
throw new Error(`Failed to create branch: ${createBranchResult.stderr}`)
|
||||
}
|
||||
|
||||
updateProgress()
|
||||
// Create selected workflow files
|
||||
const workflows = []
|
||||
|
||||
if (selectedWorkflows.includes('claude')) {
|
||||
workflows.push({
|
||||
path: '.github/workflows/claude.yml',
|
||||
content: WORKFLOW_CONTENT,
|
||||
message: 'Claude PR Assistant workflow',
|
||||
})
|
||||
}
|
||||
|
||||
if (selectedWorkflows.includes('claude-review')) {
|
||||
workflows.push({
|
||||
path: '.github/workflows/claude-code-review.yml',
|
||||
content: CODE_REVIEW_PLUGIN_WORKFLOW_CONTENT,
|
||||
message: 'Claude Code Review workflow',
|
||||
})
|
||||
}
|
||||
|
||||
for (const workflow of workflows) {
|
||||
await createWorkflowFile(
|
||||
repoName,
|
||||
branchName,
|
||||
workflow.path,
|
||||
workflow.content,
|
||||
secretName,
|
||||
workflow.message,
|
||||
context,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
updateProgress()
|
||||
// Set the API key as a secret if provided
|
||||
if (apiKeyOrOAuthToken) {
|
||||
const setSecretResult = await execFileNoThrow('gh', [
|
||||
'secret',
|
||||
'set',
|
||||
secretName,
|
||||
'--body',
|
||||
apiKeyOrOAuthToken,
|
||||
'--repo',
|
||||
repoName,
|
||||
])
|
||||
if (setSecretResult.code !== 0) {
|
||||
logEvent('tengu_setup_github_actions_failed', {
|
||||
reason:
|
||||
'failed_to_set_api_key_secret' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
||||
exit_code: setSecretResult.code,
|
||||
...context,
|
||||
})
|
||||
|
||||
const helpText =
|
||||
'\n\nNeed help? Common issues:\n' +
|
||||
'· Permission denied → Run: gh auth refresh -h github.com -s repo\n' +
|
||||
'· Not authorized → Ensure you have admin access to the repository\n' +
|
||||
'· For manual setup → Visit: https://github.com/anthropics/claude-code-action'
|
||||
|
||||
throw new Error(
|
||||
`Failed to set API key secret: ${setSecretResult.stderr || 'Unknown error'}${helpText}`,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (!skipWorkflow && branchName) {
|
||||
updateProgress()
|
||||
// Create PR template URL instead of creating PR directly
|
||||
const compareUrl = `https://github.com/${repoName}/compare/${defaultBranch}...${branchName}?quick_pull=1&title=${encodeURIComponent(PR_TITLE)}&body=${encodeURIComponent(PR_BODY)}`
|
||||
|
||||
await openBrowser(compareUrl)
|
||||
}
|
||||
|
||||
logEvent('tengu_setup_github_actions_completed', {
|
||||
skip_workflow: skipWorkflow,
|
||||
has_api_key: !!apiKeyOrOAuthToken,
|
||||
auth_type:
|
||||
authType as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
||||
using_default_secret_name: secretName === 'ANTHROPIC_API_KEY',
|
||||
selected_claude_workflow: selectedWorkflows.includes('claude'),
|
||||
selected_claude_review_workflow:
|
||||
selectedWorkflows.includes('claude-review'),
|
||||
...context,
|
||||
})
|
||||
saveGlobalConfig(current => ({
|
||||
...current,
|
||||
githubActionSetupCount: (current.githubActionSetupCount ?? 0) + 1,
|
||||
}))
|
||||
} catch (error) {
|
||||
if (
|
||||
!error ||
|
||||
!(error instanceof Error) ||
|
||||
!error.message.includes('Failed to')
|
||||
) {
|
||||
logEvent('tengu_setup_github_actions_failed', {
|
||||
reason:
|
||||
'unexpected_error' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
||||
...context,
|
||||
})
|
||||
}
|
||||
if (error instanceof Error) {
|
||||
logError(error)
|
||||
}
|
||||
throw error
|
||||
}
|
||||
}
|
||||
12
src/commands/install-slack-app/index.ts
Normal file
12
src/commands/install-slack-app/index.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import type { Command } from '../../commands.js'
|
||||
|
||||
const installSlackApp = {
|
||||
type: 'local',
|
||||
name: 'install-slack-app',
|
||||
description: 'Install the Claude Slack app',
|
||||
availability: ['claude-ai'],
|
||||
supportsNonInteractive: false,
|
||||
load: () => import('./install-slack-app.js'),
|
||||
} satisfies Command
|
||||
|
||||
export default installSlackApp
|
||||
30
src/commands/install-slack-app/install-slack-app.ts
Normal file
30
src/commands/install-slack-app/install-slack-app.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import type { LocalCommandResult } from '../../commands.js'
|
||||
import { logEvent } from '../../services/analytics/index.js'
|
||||
import { openBrowser } from '../../utils/browser.js'
|
||||
import { saveGlobalConfig } from '../../utils/config.js'
|
||||
|
||||
const SLACK_APP_URL = 'https://slack.com/marketplace/A08SF47R6P4-claude'
|
||||
|
||||
export async function call(): Promise<LocalCommandResult> {
|
||||
logEvent('tengu_install_slack_app_clicked', {})
|
||||
|
||||
// Track that user has clicked to install
|
||||
saveGlobalConfig(current => ({
|
||||
...current,
|
||||
slackAppInstallCount: (current.slackAppInstallCount ?? 0) + 1,
|
||||
}))
|
||||
|
||||
const success = await openBrowser(SLACK_APP_URL)
|
||||
|
||||
if (success) {
|
||||
return {
|
||||
type: 'text',
|
||||
value: 'Opening Slack app installation page in browser…',
|
||||
}
|
||||
} else {
|
||||
return {
|
||||
type: 'text',
|
||||
value: `Couldn't open browser. Visit: ${SLACK_APP_URL}`,
|
||||
}
|
||||
}
|
||||
}
|
||||
300
src/commands/install.tsx
Normal file
300
src/commands/install.tsx
Normal file
File diff suppressed because one or more lines are too long
1
src/commands/issue/index.js
Normal file
1
src/commands/issue/index.js
Normal file
@@ -0,0 +1 @@
|
||||
export default { isEnabled: () => false, isHidden: true, name: 'stub' };
|
||||
13
src/commands/keybindings/index.ts
Normal file
13
src/commands/keybindings/index.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import type { Command } from '../../commands.js'
|
||||
import { isKeybindingCustomizationEnabled } from '../../keybindings/loadUserBindings.js'
|
||||
|
||||
const keybindings = {
|
||||
name: 'keybindings',
|
||||
description: 'Open or create your keybindings configuration file',
|
||||
isEnabled: () => isKeybindingCustomizationEnabled(),
|
||||
supportsNonInteractive: false,
|
||||
type: 'local',
|
||||
load: () => import('./keybindings.js'),
|
||||
} satisfies Command
|
||||
|
||||
export default keybindings
|
||||
53
src/commands/keybindings/keybindings.ts
Normal file
53
src/commands/keybindings/keybindings.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
import { mkdir, writeFile } from 'fs/promises'
|
||||
import { dirname } from 'path'
|
||||
import {
|
||||
getKeybindingsPath,
|
||||
isKeybindingCustomizationEnabled,
|
||||
} from '../../keybindings/loadUserBindings.js'
|
||||
import { generateKeybindingsTemplate } from '../../keybindings/template.js'
|
||||
import { getErrnoCode } from '../../utils/errors.js'
|
||||
import { editFileInEditor } from '../../utils/promptEditor.js'
|
||||
|
||||
export async function call(): Promise<{ type: 'text'; value: string }> {
|
||||
if (!isKeybindingCustomizationEnabled()) {
|
||||
return {
|
||||
type: 'text',
|
||||
value:
|
||||
'Keybinding customization is not enabled. This feature is currently in preview.',
|
||||
}
|
||||
}
|
||||
|
||||
const keybindingsPath = getKeybindingsPath()
|
||||
|
||||
// Write template with 'wx' flag (exclusive create) — fails with EEXIST if
|
||||
// the file already exists. Avoids a stat pre-check (TOCTOU race + extra syscall).
|
||||
let fileExists = false
|
||||
await mkdir(dirname(keybindingsPath), { recursive: true })
|
||||
try {
|
||||
await writeFile(keybindingsPath, generateKeybindingsTemplate(), {
|
||||
encoding: 'utf-8',
|
||||
flag: 'wx',
|
||||
})
|
||||
} catch (e: unknown) {
|
||||
if (getErrnoCode(e) === 'EEXIST') {
|
||||
fileExists = true
|
||||
} else {
|
||||
throw e
|
||||
}
|
||||
}
|
||||
|
||||
// Open in editor
|
||||
const result = await editFileInEditor(keybindingsPath)
|
||||
if (result.error) {
|
||||
return {
|
||||
type: 'text',
|
||||
value: `${fileExists ? 'Opened' : 'Created'} ${keybindingsPath}. Could not open in editor: ${result.error}`,
|
||||
}
|
||||
}
|
||||
return {
|
||||
type: 'text',
|
||||
value: fileExists
|
||||
? `Opened ${keybindingsPath} in your editor.`
|
||||
: `Created ${keybindingsPath} with template. Opened in your editor.`,
|
||||
}
|
||||
}
|
||||
14
src/commands/login/index.ts
Normal file
14
src/commands/login/index.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import type { Command } from '../../commands.js'
|
||||
import { hasAnthropicApiKeyAuth } from '../../utils/auth.js'
|
||||
import { isEnvTruthy } from '../../utils/envUtils.js'
|
||||
|
||||
export default () =>
|
||||
({
|
||||
type: 'local-jsx',
|
||||
name: 'login',
|
||||
description: hasAnthropicApiKeyAuth()
|
||||
? 'Switch Anthropic accounts'
|
||||
: 'Sign in with your Anthropic account',
|
||||
isEnabled: () => !isEnvTruthy(process.env.DISABLE_LOGIN_COMMAND),
|
||||
load: () => import('./login.js'),
|
||||
}) satisfies Command
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user