takt/src/infra/claude/options-builder.ts
2026-02-02 21:52:40 +09:00

186 lines
5.2 KiB
TypeScript

/**
* SDK options builder for Claude queries
*
* Builds the options object for Claude Agent SDK queries,
* including permission handlers and hooks.
*/
import type {
Options,
CanUseTool,
PermissionResult,
PermissionUpdate,
HookCallbackMatcher,
HookInput,
HookJSONOutput,
PreToolUseHookInput,
PermissionMode,
} from '@anthropic-ai/claude-agent-sdk';
import { createLogger } from '../../shared/utils/index.js';
import type {
PermissionHandler,
AskUserQuestionInput,
AskUserQuestionHandler,
ClaudeSpawnOptions,
} from './types.js';
const log = createLogger('claude-sdk');
/**
* Builds SDK options from ClaudeSpawnOptions.
*
* Handles permission mode resolution, canUseTool callback creation,
* and AskUserQuestion hook setup.
*/
export class SdkOptionsBuilder {
private readonly options: ClaudeSpawnOptions;
constructor(options: ClaudeSpawnOptions) {
this.options = options;
}
/** Build the full SDK Options object */
build(): Options {
const canUseTool = this.options.onPermissionRequest
? SdkOptionsBuilder.createCanUseToolCallback(this.options.onPermissionRequest)
: undefined;
const hooks = this.options.onAskUserQuestion
? SdkOptionsBuilder.createAskUserQuestionHooks(this.options.onAskUserQuestion)
: undefined;
const permissionMode = this.resolvePermissionMode();
// Only include defined values — the SDK treats key-present-but-undefined
// differently from key-absent for some options (e.g. model), causing hangs.
const sdkOptions: Options = {
cwd: this.options.cwd,
permissionMode,
};
if (this.options.model) sdkOptions.model = this.options.model;
if (this.options.maxTurns != null) sdkOptions.maxTurns = this.options.maxTurns;
if (this.options.allowedTools) sdkOptions.allowedTools = this.options.allowedTools;
if (this.options.agents) sdkOptions.agents = this.options.agents;
if (this.options.systemPrompt) sdkOptions.systemPrompt = this.options.systemPrompt;
if (canUseTool) sdkOptions.canUseTool = canUseTool;
if (hooks) sdkOptions.hooks = hooks;
if (this.options.anthropicApiKey) {
sdkOptions.env = {
...process.env as Record<string, string>,
ANTHROPIC_API_KEY: this.options.anthropicApiKey,
};
}
if (this.options.onStream) {
sdkOptions.includePartialMessages = true;
}
if (this.options.sessionId) {
sdkOptions.resume = this.options.sessionId;
} else {
sdkOptions.continue = false;
}
return sdkOptions;
}
/** Resolve permission mode with priority: bypassPermissions > explicit > callback-based > default */
private resolvePermissionMode(): PermissionMode {
if (this.options.bypassPermissions) {
return 'bypassPermissions';
}
if (this.options.permissionMode) {
return this.options.permissionMode;
}
if (this.options.onPermissionRequest) {
return 'default';
}
return 'acceptEdits';
}
/**
* Create canUseTool callback from permission handler.
*/
static createCanUseToolCallback(
handler: PermissionHandler
): CanUseTool {
return async (
toolName: string,
input: Record<string, unknown>,
callbackOptions: {
signal: AbortSignal;
suggestions?: PermissionUpdate[];
blockedPath?: string;
decisionReason?: string;
}
): Promise<PermissionResult> => {
return handler({
toolName,
input,
suggestions: callbackOptions.suggestions,
blockedPath: callbackOptions.blockedPath,
decisionReason: callbackOptions.decisionReason,
});
};
}
/**
* Create hooks for AskUserQuestion handling.
*/
static createAskUserQuestionHooks(
askUserHandler: AskUserQuestionHandler
): Partial<Record<string, HookCallbackMatcher[]>> {
const preToolUseHook = async (
input: HookInput,
_toolUseID: string | undefined,
_options: { signal: AbortSignal }
): Promise<HookJSONOutput> => {
const preToolInput = input as PreToolUseHookInput;
if (preToolInput.tool_name === 'AskUserQuestion') {
const toolInput = preToolInput.tool_input as AskUserQuestionInput;
try {
const answers = await askUserHandler(toolInput);
return {
continue: true,
hookSpecificOutput: {
hookEventName: 'PreToolUse',
additionalContext: JSON.stringify(answers),
},
};
} catch (err) {
log.error('AskUserQuestion handler failed', { error: err });
return { continue: true };
}
}
return { continue: true };
};
return {
PreToolUse: [{
matcher: 'AskUserQuestion',
hooks: [preToolUseHook],
}],
};
}
}
// ---- Backward-compatible module-level functions ----
export function createCanUseToolCallback(
handler: PermissionHandler
): CanUseTool {
return SdkOptionsBuilder.createCanUseToolCallback(handler);
}
export function createAskUserQuestionHooks(
askUserHandler: AskUserQuestionHandler
): Partial<Record<string, HookCallbackMatcher[]>> {
return SdkOptionsBuilder.createAskUserQuestionHooks(askUserHandler);
}
export function buildSdkOptions(options: ClaudeSpawnOptions): Options {
return new SdkOptionsBuilder(options).build();
}