Compare commits

...
91 changed files with 176 additions and 10553 deletions
+3 -53
View File
@@ -70,18 +70,9 @@ service FileService {
// Opens or creates a focus chain checklist markdown file for editing
rpc openFocusChainFile(StringRequest) returns (Empty);
// Refreshes all hook toggles (discovers hooks and their enabled state)
// Refreshes SDK hook config files discovered from global and workspace search paths
rpc refreshHooks(EmptyRequest) returns (HooksToggles);
// Toggles a hook on or off via chmod +x/-x
rpc toggleHook(ToggleHookRequest) returns (ToggleHookResponse);
// Creates a new hook from template
rpc createHook(CreateHookRequest) returns (CreateHookResponse);
// Deletes an existing hook file
rpc deleteHook(DeleteHookRequest) returns (DeleteHookResponse);
// Refreshes all skill toggles (discovers skills and their enabled state)
rpc refreshSkills(EmptyRequest) returns (RefreshedSkills);
@@ -243,11 +234,11 @@ message ToggleWorkflowRequest {
RuleScope scope = 4; // Scope of the workflow (local, global, or remote)
}
// Maps from hook name to enabled/disabled status
// SDK hook config file discovered from global or workspace search paths
message HookInfo {
string name = 1;
bool enabled = 2;
string absolute_path = 3;
string hook_event_name = 4;
}
message WorkspaceHooks {
@@ -258,47 +249,6 @@ message WorkspaceHooks {
message HooksToggles {
repeated HookInfo global_hooks = 1;
repeated WorkspaceHooks workspace_hooks = 2;
bool is_windows = 3; // Whether the system is Windows (toggles disabled)
}
// Request to toggle a hook
message ToggleHookRequest {
Metadata metadata = 1;
string hook_name = 2; // Name of the hook (e.g., "TaskStart")
bool is_global = 3; // Whether this is a global or workspace hook
bool enabled = 4; // Whether to enable (chmod +x) or disable (chmod -x)
optional string workspace_name = 5; // For multi-root workspaces, specifies which workspace
}
// Response for toggleHook operation
message ToggleHookResponse {
HooksToggles hooks_toggles = 1;
}
// Request to create a hook
message CreateHookRequest {
Metadata metadata = 1;
string hook_name = 2; // Name of the hook to create
bool is_global = 3; // Whether to create in global or workspace hooks directory
optional string workspace_name = 4; // For multi-root workspaces, specifies which workspace
}
// Response for createHook operation
message CreateHookResponse {
HooksToggles hooks_toggles = 1;
}
// Request to delete a hook
message DeleteHookRequest {
Metadata metadata = 1;
string hook_name = 2; // Name of the hook to delete
bool is_global = 3; // Whether this is a global or workspace hook
optional string workspace_name = 4; // For multi-root workspaces, specifies which workspace
}
// Response for deleteHook operation
message DeleteHookResponse {
HooksToggles hooks_toggles = 1;
}
// Skill information structure
-143
View File
@@ -1,143 +0,0 @@
syntax = "proto3";
package cline;
option go_package = "github.com/cline/grpc-go/cline";
option java_multiple_files = true;
option java_package = "bot.cline.proto";
// Input message for all hooks
message HookInput {
string cline_version = 1;
string hook_name = 2;
string timestamp = 3;
string task_id = 4;
repeated string workspace_roots = 5;
string user_id = 6;
HookModelContext model = 7;
oneof data {
PreToolUseData pre_tool_use = 10;
PostToolUseData post_tool_use = 11;
UserPromptSubmitData user_prompt_submit = 12;
TaskStartData task_start = 13;
TaskResumeData task_resume = 14;
TaskCancelData task_cancel = 15;
TaskCompleteData task_complete = 16;
PreCompactData pre_compact = 17;
NotificationData notification = 18;
}
}
message HookModelContext {
string provider = 1;
string slug = 2;
}
// Output message for all hooks
message HookOutput {
string context_modification = 1;
bool cancel = 2;
string error_message = 3;
}
// Data for PreToolUse hook
message PreToolUseData {
string tool_name = 1;
map<string, string> parameters = 2;
}
// Data for PostToolUse hook
message PostToolUseData {
string tool_name = 1;
map<string, string> parameters = 2;
string result = 3;
bool success = 4;
int64 execution_time_ms = 5;
}
// Data for UserPromptSubmit hook
message UserPromptSubmitData {
string prompt = 1;
repeated string attachments = 2;
}
// Data for Notification hook
message NotificationData {
string event = 1;
string source = 2;
string message = 3;
bool waiting_for_user_input = 4;
string event_version = 5;
string event_id = 6;
bool message_truncated = 7;
string source_type = 8;
string source_id = 9;
bool requires_user_action = 10;
string severity = 11;
}
// Data for TaskStart hook
message TaskStartData {
map<string, string> task_metadata = 1;
}
// Data for TaskResume hook
message TaskResumeData {
map<string, string> task_metadata = 1;
map<string, string> previous_state = 2;
}
// Data for TaskCancel hook
message TaskCancelData {
map<string, string> task_metadata = 1;
}
// Data for TaskComplete hook
message TaskCompleteData {
map<string, string> task_metadata = 1;
}
// Data for PreCompact hook
message PreCompactData {
// Task identification
string task_id = 1;
string ulid = 2;
// Context size information
int64 context_size = 3; // Number of messages in API conversation history
// Compaction strategy indicating how conversation history is managed:
// * auto-condense: AI-powered compression using summarize_task tool
// * standard-truncation-firstpair: Keep only the original task (used during auto-condense)
// * standard-truncation-lasthalf: Keep first pair + most recent 50% of conversation
// * standard-truncation-lastquarter: Keep first pair + most recent 25% of conversation (aggressive)
string compaction_strategy = 4;
// API request tracking
int64 previous_api_req_index = 5; // Index of last API request in clineMessages
// Token usage data from last API request
int64 tokens_in = 6;
int64 tokens_out = 7;
int64 tokens_in_cache = 8;
int64 tokens_out_cache = 9;
// Truncation information (if applicable)
int32 deleted_range_start = 10; // Start index of deleted conversation range
int32 deleted_range_end = 11; // End index of deleted conversation range
// Context JSON file path
// Path to a temporary JSON file containing the full API conversation history
// The file contains an array of message objects with role and content
// Hooks can read this file to analyze conversation contents before compaction
// This file will be automatically cleaned up after the hook completes
string context_json_path = 12;
// Context raw/formatted file path
// Path to a temporary text file containing the complete context window sent to the LLM
// This includes the system prompt, environment details, conversation history, and all formatting
// Represents the actual input the LLM receives (format varies by provider)
// Use this to analyze total context size, overhead, and exactly what the model sees
// This file will be automatically cleaned up after the hook completes
string context_raw_path = 13;
}
+2 -2
View File
@@ -258,7 +258,7 @@ message Settings {
optional PlanActMode mode = 147;
optional FocusChainSettings focus_chain_settings = 149;
optional string custom_prompt = 150;
optional bool hooks_enabled = 152;
reserved 152;
optional bool subagents_enabled = 153;
optional bool background_edit_enabled = 155;
optional bool opt_out_of_remote_config = 157;
@@ -413,7 +413,7 @@ message UpdateSettingsRequest {
optional string default_terminal_profile = 21;
optional bool yolo_mode_toggled = 22;
optional bool multi_root_enabled = 25;
optional bool hooks_enabled = 26;
reserved 26;
optional string vscode_terminal_execution_mode = 27;
optional int32 max_consecutive_mistakes = 28;
optional bool subagents_enabled = 29;
-6
View File
@@ -5,8 +5,6 @@ import { HostProvider } from "@/hosts/host-provider"
import { Logger } from "@/shared/services/Logger"
import type { StorageContext } from "@/shared/storage/storage-context"
import { clearOnboardingModelsCache } from "./core/controller/models/getClineOnboardingModels"
import { HookDiscoveryCache } from "./core/hooks/HookDiscoveryCache"
import { HookProcessRegistry } from "./core/hooks/HookProcessRegistry"
import { StateManager } from "./core/storage/StateManager"
import { AgentConfigLoader } from "./core/task/tools/subagent/AgentConfigLoader"
import { ExtensionRegistryInfo } from "./registry"
@@ -164,10 +162,6 @@ export async function tearDown(): Promise<void> {
syncWorker().dispose()
clearOnboardingModelsCache()
// Kill any running hook processes to prevent zombies
await HookProcessRegistry.terminateAll()
// Clean up hook discovery cache
HookDiscoveryCache.getInstance().dispose()
// Stop periodic temp file cleanup
ClineTempManager.stopPeriodicCleanup()
} finally {
@@ -1,56 +0,0 @@
import { CreateHookRequest, CreateHookResponse } from "@shared/proto/cline/file"
import fs from "fs/promises"
import path from "path"
import { HookDiscoveryCache } from "../../hooks/HookDiscoveryCache"
import { getHookTemplate } from "../../hooks/templates"
import { isValidHookType, resolveHooksDirectory, VALID_HOOK_TYPES } from "../../hooks/utils"
import { Controller } from ".."
import { refreshHooks } from "./refreshHooks"
export async function createHook(
controller: Controller,
request: CreateHookRequest,
globalHooksDirOverride?: string,
): Promise<CreateHookResponse> {
const { hookName, isGlobal, workspaceName } = request
// Validate hook name is one of the valid hook types
if (!isValidHookType(hookName)) {
throw new Error(`Invalid hook type: "${hookName}". Valid hook types are: ${VALID_HOOK_TYPES.join(", ")}`)
}
// Determine target directory
const hooksDir = await resolveHooksDirectory(isGlobal, workspaceName, globalHooksDirOverride)
// Ensure directory exists
await fs.mkdir(hooksDir, { recursive: true })
const hookFileName = process.platform === "win32" ? `${hookName}.ps1` : hookName
const hookPath = path.join(hooksDir, hookFileName)
// Check if already exists
try {
await fs.stat(hookPath)
throw new Error(`Hook ${hookName} already exists at ${hookPath}`)
} catch (error) {
// Good - file doesn't exist yet
if ((error as NodeJS.ErrnoException).code !== "ENOENT") {
throw error
}
}
// Get template content
const templateContent = getHookTemplate(hookName)
// Write file WITHOUT executable permissions (644) so hook is toggled off by default
// User can enable it later when they're ready
const mode = 0o644
await fs.writeFile(hookPath, templateContent, { mode })
// Invalidate hook discovery cache
await HookDiscoveryCache.getInstance().invalidateAll()
// Return updated hooks state
const hooksToggles = await refreshHooks(controller, undefined, globalHooksDirOverride)
return CreateHookResponse.create({ hooksToggles })
}
@@ -1,33 +0,0 @@
import { DeleteHookRequest, DeleteHookResponse } from "@shared/proto/cline/file"
import fs from "fs/promises"
import { HookDiscoveryCache } from "../../hooks/HookDiscoveryCache"
import { resolveExistingHookPath, resolveHooksDirectory } from "../../hooks/utils"
import { Controller } from ".."
import { refreshHooks } from "./refreshHooks"
export async function deleteHook(
controller: Controller,
request: DeleteHookRequest,
globalHooksDirOverride?: string,
): Promise<DeleteHookResponse> {
const { hookName, isGlobal, workspaceName } = request
// Determine hook path
const hooksDir = await resolveHooksDirectory(isGlobal, workspaceName, globalHooksDirOverride)
const hookPath = await resolveExistingHookPath(hooksDir, hookName)
// Verify hook exists before attempting deletion
if (!hookPath) {
throw new Error(`Hook ${hookName} does not exist in ${hooksDir}`)
}
// Delete the hook file
await fs.unlink(hookPath)
// Invalidate hook discovery cache
await HookDiscoveryCache.getInstance().invalidateAll()
// Return updated hooks state
const hooksToggles = await refreshHooks(controller, undefined, globalHooksDirOverride)
return DeleteHookResponse.create({ hooksToggles })
}
@@ -1,57 +1,31 @@
import { listHookConfigFiles } from "@cline/core"
import { HookInfo, HooksToggles, WorkspaceHooks } from "@shared/proto/cline/file"
import fs from "fs/promises"
import os from "os"
import path from "path"
import { HostProvider } from "@/hosts/host-provider"
import { resolveExistingHookPath, VALID_HOOK_TYPES } from "../../hooks/utils"
import { Controller } from ".."
export async function refreshHooks(
_controller: Controller,
_request?: any,
globalHooksDirOverride?: string,
): Promise<HooksToggles> {
const globalHooksDir = globalHooksDirOverride || path.join(os.homedir(), "Documents", "Cline", "Hooks")
const isWindows = process.platform === "win32"
export async function refreshHooks(_controller: Controller, _request?: any): Promise<HooksToggles> {
const toHookInfo = (entry: ReturnType<typeof listHookConfigFiles>[number]): HookInfo =>
HookInfo.create({
name: entry.fileName,
absolutePath: entry.path,
hookEventName: entry.hookEventName ?? "",
})
// Collect global hooks
const globalHooks: HookInfo[] = []
for (const hookName of VALID_HOOK_TYPES) {
const hookPath = await resolveExistingHookPath(globalHooksDir, hookName)
if (hookPath) {
globalHooks.push(
HookInfo.create({
name: hookName,
enabled: await isExecutable(hookPath),
absolutePath: hookPath,
}),
)
}
}
const globalEntries = listHookConfigFiles()
const globalHookPaths = new Set(globalEntries.map((entry) => entry.path))
const globalHooks = globalEntries.map(toHookInfo)
// Collect workspace hooks from all workspace folders
const workspacePaths = await HostProvider.workspace.getWorkspacePaths({})
const workspaceHooksList: WorkspaceHooks[] = []
for (const workspacePath of workspacePaths.paths) {
const workspaceHooksDir = path.join(workspacePath, ".clinerules", "hooks")
const hooks: HookInfo[] = []
for (const hookName of VALID_HOOK_TYPES) {
const hookPath = await resolveExistingHookPath(workspaceHooksDir, hookName)
if (hookPath) {
hooks.push(
HookInfo.create({
name: hookName,
enabled: await isExecutable(hookPath),
absolutePath: hookPath,
}),
)
}
}
const hooks = listHookConfigFiles(workspacePath)
.filter((entry) => !globalHookPaths.has(entry.path))
.map(toHookInfo)
// Add all workspaces, even if they have no hooks yet
// This allows users to create their first hook via the dropdown
const workspaceName = path.basename(workspacePath)
workspaceHooksList.push(
WorkspaceHooks.create({
@@ -64,22 +38,5 @@ export async function refreshHooks(
return HooksToggles.create({
globalHooks,
workspaceHooks: workspaceHooksList,
isWindows,
})
}
async function isExecutable(filePath: string): Promise<boolean> {
if (process.platform === "win32") {
// On Windows, files are "enabled" if they exist
// TODO(PR-9552 follow-up): Replace this temporary file-exists behavior
// with JSON-backed cross-platform hook enablement state.
return true
}
try {
await fs.access(filePath, fs.constants.X_OK)
return true
} catch {
return false
}
}
@@ -1,41 +0,0 @@
import { ToggleHookRequest, ToggleHookResponse } from "@shared/proto/cline/file"
import fs from "fs/promises"
import { HookDiscoveryCache } from "../../hooks/HookDiscoveryCache"
import { resolveExistingHookPath, resolveHooksDirectory } from "../../hooks/utils"
import { Controller } from ".."
import { refreshHooks } from "./refreshHooks"
export async function toggleHook(
controller: Controller,
request: ToggleHookRequest,
globalHooksDirOverride?: string,
): Promise<ToggleHookResponse> {
const { hookName, isGlobal, enabled, workspaceName } = request
// Determine hook path
const hooksDir = await resolveHooksDirectory(isGlobal, workspaceName, globalHooksDirOverride)
const hookPath = await resolveExistingHookPath(hooksDir, hookName)
// Verify hook exists
if (!hookPath) {
throw new Error(`Hook ${hookName} does not exist in ${hooksDir}`)
}
// On Windows, we can't use chmod, so we just return the current state
// without modifying the file. The frontend will disable the toggle.
// TODO(PR-9552 follow-up): Replace this temporary behavior with a
// JSON-backed cross-platform enabled/disabled hook state.
if (process.platform !== "win32") {
// Toggle executable bit (Unix-like systems only)
// TODO(PR-9552 follow-up): Revisit chmod-driven enablement semantics
// once cross-platform JSON-backed state is implemented.
await fs.chmod(hookPath, enabled ? 0o755 : 0o644)
}
// Invalidate cache
await HookDiscoveryCache.getInstance().invalidateAll()
// Return updated state
const hooksToggles = await refreshHooks(controller, undefined, globalHooksDirOverride)
return ToggleHookResponse.create({ hooksToggles })
}
@@ -4,7 +4,6 @@
// This allows the SdkController to reuse the classic state-building logic
// without inheriting the entire classic Controller implementation.
import { getHooksEnabledSafe } from "@core/hooks/hooks-utils"
import type { ExtensionState, Platform } from "@shared/ExtensionMessage"
import { ClineEnv } from "@/config"
import { ExtensionRegistryInfo } from "@/registry"
@@ -165,7 +164,6 @@ export async function getStateToPostToWebview(controller: {
user: stateManager.getGlobalSettingsKey("worktreesEnabled"),
featureFlag: featureFlagsService.getWorktreesEnabled(),
},
hooksEnabled: getHooksEnabledSafe(stateManager.getGlobalSettingsKey("hooksEnabled")),
lastDismissedInfoBannerVersion,
lastDismissedModelBannerVersion,
remoteConfigSettings: stateManager.getRemoteConfigSettings?.(),
@@ -134,14 +134,6 @@ export async function updateSettings(controller: Controller, request: UpdateSett
controller.stateManager.setGlobalState("maxConsecutiveMistakes", Number(request.maxConsecutiveMistakes))
}
if (request.hooksEnabled !== undefined) {
const wasEnabled = controller.stateManager.getGlobalSettingsKey("hooksEnabled") ?? true
const isEnabled = !!request.hooksEnabled
controller.stateManager.setGlobalState("hooksEnabled", isEnabled)
if (controller.task && wasEnabled !== isEnabled) {
telemetryService.captureFeatureToggle(controller.task.ulid, "hooks", isEnabled, controller.task.api.getModel().id)
}
}
// Update yolo mode setting
if (request.yoloModeToggled !== undefined) {
if (controller.task) {
@@ -1,329 +0,0 @@
import { Logger } from "@/shared/services/Logger"
import { telemetryService } from "../../services/telemetry"
import { getAllHooksDirs } from "../storage/disk"
import { HookFactory, Hooks } from "./hook-factory"
type HookName = keyof Hooks
/**
* Cached hook discovery results
*/
interface HookCacheEntry {
scriptPaths: string[] // Paths to hook scripts for this hook name
timestamp: number // When this was last scanned
}
/**
* Generic disposable interface for resource cleanup
*/
interface Disposable {
dispose(): void
}
/**
* Generic file watcher interface
*/
interface FileWatcher extends Disposable {
onDidCreate(listener: () => void): void
onDidChange(listener: () => void): void
onDidDelete(listener: () => void): void
}
/**
* Generic context interface for managing subscriptions
*/
interface ExtensionContext {
subscriptions: Disposable[]
}
/**
* Singleton cache for hook script discovery with lazy file system watching.
*
* Features:
* - Lazy watcher initialization (only when directories are accessed)
* - Per-directory caching
* - Automatic invalidation on file changes
* - Graceful error handling
* - Optional debug logging
*/
export class HookDiscoveryCache {
private static instance: HookDiscoveryCache | null = null
// Cache: hookName -> discovered script paths
private cache = new Map<HookName, HookCacheEntry>()
// Watchers: directory path -> file watcher
private watchers = new Map<string, FileWatcher>()
// Directories we've tried to watch (even if watcher creation failed)
private watchedDirs = new Set<string>()
// Currently scanning promises (to prevent concurrent scans)
private scanningPromises = new Map<HookName, Promise<string[]>>()
// For disposal
private context: ExtensionContext | null = null
private createFileWatcher: ((dir: string) => FileWatcher | null) | null = null
private disposed = false
// Debug logging (enabled via DEBUG_HOOKS env var)
private debug = process.env.DEBUG_HOOKS === "true"
private constructor() {}
static getInstance(): HookDiscoveryCache {
if (!HookDiscoveryCache.instance) {
HookDiscoveryCache.instance = new HookDiscoveryCache()
}
return HookDiscoveryCache.instance
}
/**
* Initialize with extension context for proper cleanup
*/
initialize(
context: ExtensionContext,
createFileWatcher?: (dir: string) => FileWatcher | null,
onWorkspaceFoldersChanged?: (callback: () => void) => Disposable,
): void {
this.context = context
this.createFileWatcher = createFileWatcher || null
// Watch for workspace changes to invalidate cache (if callback provided)
if (onWorkspaceFoldersChanged) {
context.subscriptions.push(
onWorkspaceFoldersChanged(() => {
this.log("Workspace folders changed, invalidating cache")
this.invalidateAll()
}),
)
}
}
/**
* Get cached hook scripts or scan if not cached
*/
async get(hookName: HookName): Promise<string[]> {
this.log(`Getting hooks for ${hookName}`)
const cached = this.cache.get(hookName)
const cacheHit = cached !== undefined
let scripts: string[]
let initiatedScan = false // Track if this caller initiated the scan
if (cacheHit) {
this.log(`Cache hit for ${hookName}: ${cached.scriptPaths.length} scripts`)
scripts = cached.scriptPaths
} else {
this.log(`Cache miss for ${hookName}, scanning...`)
// Check if scan is already in progress
const existingPromise = this.scanningPromises.get(hookName)
if (existingPromise) {
// Another caller is already scanning, reuse their promise
this.log(`Reusing existing scan for ${hookName}`)
scripts = await existingPromise
} else {
// This caller initiates the scan
initiatedScan = true
scripts = await this.scan(hookName)
}
}
// Only report telemetry if:
// 1. It was a cache hit, OR
// 2. This caller initiated the scan (not reusing another caller's promise)
if (cacheHit || initiatedScan) {
telemetryService.safeCapture(
() => telemetryService.captureHookCacheAccess(hookName, cacheHit),
"HookDiscoveryCache.get",
)
}
return scripts
}
/**
* Scan for hook scripts and cache the result
*/
private async scan(hookName: HookName): Promise<string[]> {
// Check if a scan is already in progress for this hook
const existingPromise = this.scanningPromises.get(hookName)
if (existingPromise) {
this.log(`Already scanning ${hookName}, waiting for existing scan...`)
return existingPromise
}
// Create a new scan promise
const scanPromise = (async () => {
try {
// Get all current hooks directories
const hooksDirs = await getAllHooksDirs()
this.log(`Scanning ${hooksDirs.length} directories for ${hookName}`)
// Ensure watchers are set up for each directory (lazy initialization)
for (const dir of hooksDirs) {
this.ensureWatcher(dir)
}
// Scan each directory for this hook
const scriptPromises = hooksDirs.map((dir) => HookFactory.findHookInHooksDir(hookName, dir))
const results = await Promise.all(scriptPromises)
const scripts = results.filter((path): path is string => path !== undefined)
this.log(`Found ${scripts.length} scripts for ${hookName}`)
// Cache the result
this.cache.set(hookName, {
scriptPaths: scripts,
timestamp: Date.now(),
})
return scripts
} catch (error) {
Logger.error(`Error scanning for ${hookName} hooks:`, error)
// Return empty array on error - don't break the whole system
return []
} finally {
// Remove from scanning promises map
this.scanningPromises.delete(hookName)
}
})()
// Store the promise so concurrent calls can await it
this.scanningPromises.set(hookName, scanPromise)
return scanPromise
}
/**
* Ensure a file watcher exists for the given directory
*/
private ensureWatcher(dir: string): void {
// Skip if already watching or tried to watch
if (this.watchedDirs.has(dir)) {
return
}
this.watchedDirs.add(dir)
if (!this.context) {
this.log(`No context available, skipping watcher for ${dir}`)
return
}
// If no watcher creation function provided, skip watching
if (!this.createFileWatcher) {
this.log(`No watcher creator available, skipping watcher for ${dir}`)
return
}
try {
// Create watcher using the provided function
const watcher = this.createFileWatcher(dir)
if (!watcher) {
this.log(`Watcher creation returned null for ${dir}`)
return
}
// Invalidate cache on any change
const invalidate = () => {
this.log(`File change detected in ${dir}, invalidating cache`)
this.invalidateDirectory(dir)
}
watcher.onDidCreate(invalidate)
watcher.onDidChange(invalidate)
watcher.onDidDelete(invalidate)
// Add to context subscriptions for proper cleanup
if (this.context) {
this.context.subscriptions.push(watcher)
}
this.watchers.set(dir, watcher)
this.log(`Created watcher for ${dir}`)
} catch (error) {
// Log but don't fail - directory might not exist yet
this.log(`Failed to create watcher for ${dir}: ${error}`)
}
}
/**
* Invalidate all cached hooks that have scripts in this directory
*/
private invalidateDirectory(dir: string): void {
let invalidated = 0
for (const [hookName, entry] of this.cache) {
if (entry.scriptPaths.some((scriptPath) => scriptPath.startsWith(dir))) {
this.cache.delete(hookName)
invalidated++
}
}
this.log(`Invalidated ${invalidated} hooks for directory ${dir}`)
}
/**
* Invalidate entire cache
*/
invalidateAll(): void {
const size = this.cache.size
this.cache.clear()
this.log(`Invalidated entire cache (${size} entries)`)
}
/**
* Get cache statistics (for debugging/monitoring)
*/
getStats() {
return {
cacheSize: this.cache.size,
watcherCount: this.watchers.size,
watchedDirs: this.watchedDirs.size,
}
}
/**
* Log debug message if debug mode is enabled
*/
private log(message: string): void {
if (this.debug) {
Logger.log(`[HookCache] ${message}`)
}
}
/**
* Clean up resources
*/
dispose(): void {
if (this.disposed) {
return
}
this.log(`Disposing cache (${this.watchers.size} watchers)`)
for (const watcher of this.watchers.values()) {
watcher.dispose()
}
this.watchers.clear()
this.watchedDirs.clear()
this.cache.clear()
this.disposed = true
}
/**
* Reset singleton instance (for testing)
*/
static resetForTesting(): void {
if (HookDiscoveryCache.instance) {
HookDiscoveryCache.instance.dispose()
HookDiscoveryCache.instance = null
}
}
}
-123
View File
@@ -1,123 +0,0 @@
/**
* Types of errors that can occur during hook execution
*/
enum HookErrorType {
/** Hook execution exceeded the timeout limit */
TIMEOUT = "timeout",
/** Hook output failed JSON validation */
VALIDATION = "validation",
/** Hook script execution failed (non-zero exit, crash, etc.) */
EXECUTION = "execution",
/** Hook was cancelled by user */
CANCELLATION = "cancellation",
}
/**
* Structured error information for hook failures.
* Provides both user-friendly messages and technical details.
*/
export interface HookErrorInfo {
/** Type of error that occurred */
type: HookErrorType
/** User-friendly error message */
message: string
/** Technical details for debugging (optional, shown in expansion) */
details?: string
/** Path to the hook script that failed */
scriptPath?: string
/** Exit code if available */
exitCode?: number
/** Stderr output if available */
stderr?: string
}
/**
* Error thrown during hook execution with structured information.
* This allows proper error handling without string parsing.
*/
export class HookExecutionError extends Error {
constructor(
public readonly errorInfo: HookErrorInfo,
message?: string,
) {
super(message || errorInfo.message)
this.name = "HookExecutionError"
}
/**
* Check if an error is a HookExecutionError
*/
static isHookError(error: unknown): error is HookExecutionError {
return error instanceof HookExecutionError
}
/**
* Create a timeout error
*/
static timeout(scriptPath: string, timeoutMs: number, stderr?: string, hookName?: string): HookExecutionError {
const hookPrefix = hookName ? `${hookName} hook` : "Hook"
return new HookExecutionError({
type: HookErrorType.TIMEOUT,
message: `${hookPrefix} execution timed out after ${timeoutMs}ms`,
details:
`The hook took longer than ${timeoutMs / 1000} seconds to complete.\n\n` +
`Common causes:\n` +
`• Infinite loop in hook script\n` +
`• Network request hanging without timeout\n` +
`• File I/O operation stuck\n` +
`• Heavy computation taking too long\n\n` +
`Recommendations:\n` +
`1. Check your hook script for infinite loops\n` +
`2. Add timeouts to network requests\n` +
`3. Use background jobs for long operations\n` +
`4. Test your hook script independently`,
scriptPath,
stderr,
})
}
/**
* Create a validation error
*/
static validation(validationError: string, scriptPath: string, stdoutPreview: string): HookExecutionError {
return new HookExecutionError({
type: HookErrorType.VALIDATION,
message: "Hook output validation failed",
details: `${validationError}\n\nOutput preview:\n${stdoutPreview}`,
scriptPath,
})
}
/**
* Create an execution error
*/
static execution(scriptPath: string, exitCode: number, stderr?: string, hookName?: string): HookExecutionError {
const hookPrefix = hookName ? `${hookName} hook` : "Hook script"
const message = `${hookPrefix} exited with code ${exitCode}`
return new HookExecutionError(
{
type: HookErrorType.EXECUTION,
message,
details: stderr ? `stderr:\n${stderr}` : undefined,
scriptPath,
exitCode,
stderr,
},
message,
)
}
/**
* Create a cancellation error
*/
static cancellation(scriptPath: string, hookName?: string): HookExecutionError {
const hookPrefix = hookName ? `${hookName} hook` : "Hook"
return new HookExecutionError({
type: HookErrorType.CANCELLATION,
message: `${hookPrefix} execution was cancelled`,
details: "The hook was cancelled by the user before completion",
scriptPath,
exitCode: 130, // Standard SIGINT exit code
})
}
}
-456
View File
@@ -1,456 +0,0 @@
import { ChildProcess, spawn } from "child_process"
import { EventEmitter } from "events"
import { Logger } from "@/shared/services/Logger"
import { resolveWindowsPowerShellExecutable } from "@/utils/powershell"
import { HookProcessRegistry } from "./HookProcessRegistry"
import { escapeShellPath } from "./shell-escape"
// Maximum total output size (stdout + stderr combined)
const MAX_HOOK_OUTPUT_SIZE = 1024 * 1024 // 1MB
interface HookLaunchConfig {
command: string
args: string[]
shell: boolean
detached: boolean
}
const WINDOWS_HOOK_LAUNCHER_CACHE_TTL_MS = 5 * 60 * 1000
let resolvedHookLauncherCommandPromise: Promise<string> | null = null
let resolvedHookLauncherCommandExpiresAt = 0
export function resetHookLaunchConfigCacheForTesting(): void {
resolvedHookLauncherCommandPromise = null
resolvedHookLauncherCommandExpiresAt = 0
}
function shouldRefreshWindowsLauncherCache(now: number): boolean {
return !resolvedHookLauncherCommandPromise || now >= resolvedHookLauncherCommandExpiresAt
}
/**
* Returns the process launch configuration for a hook script.
*
* On Windows, the PowerShell executable lookup is cached briefly so concurrent
* hook launches share the same in-flight resolution and avoid repeated process
* spawning.
*
* @param scriptPath Path to the hook script to launch.
* @param resolvePowerShellExecutable Windows only. Called at most once per
* `WINDOWS_HOOK_LAUNCHER_CACHE_TTL_MS` window; subsequent concurrent or cached
* calls reuse the shared result and ignore this parameter.
*/
export async function getHookLaunchConfig(
scriptPath: string,
resolvePowerShellExecutable: () => Promise<string> = resolveWindowsPowerShellExecutable,
): Promise<HookLaunchConfig> {
if (process.platform === "win32") {
const now = Date.now()
if (shouldRefreshWindowsLauncherCache(now)) {
resolvedHookLauncherCommandPromise = resolvePowerShellExecutable().catch((error) => {
resolvedHookLauncherCommandPromise = null
resolvedHookLauncherCommandExpiresAt = 0
throw error
})
resolvedHookLauncherCommandExpiresAt = now + WINDOWS_HOOK_LAUNCHER_CACHE_TTL_MS
}
const powerShellExecutable = await resolvedHookLauncherCommandPromise!
return {
command: powerShellExecutable,
args: ["-NoProfile", "-NonInteractive", "-ExecutionPolicy", "Bypass", "-File", scriptPath],
shell: false,
detached: false,
}
}
const escapedScriptPath = escapeShellPath(scriptPath)
return {
command: escapedScriptPath,
args: [],
shell: true,
detached: true,
}
}
/**
* HookProcess manages the execution of a hook script with streaming output capabilities.
* Similar to StandaloneTerminalProcess but specialized for hook execution.
*
* Key features:
* - Real-time stdout/stderr streaming via line events
* - Separate handling of visual output vs. JSON response
* - 30-second execution timeout
* - 1MB output size limit (prevents memory issues)
* - Process lifecycle management with abort support
*/
export class HookProcess extends EventEmitter {
private childProcess: ChildProcess | null = null
private buffer = ""
private fullOutput = ""
private lastRetrievedIndex = 0
private exitCode: number | null = null
private isCompleted = false
private timeoutHandle: NodeJS.Timeout | null = null // 30-second execution timeout
// Separate buffers for stdout and stderr
private stdoutBuffer = ""
private stderrBuffer = ""
// Output size tracking
private stdoutSize = 0
private stderrSize = 0
private outputTruncated = false
// Track registration state to prevent leaks and ensure cleanup
private isRegistered = false
constructor(
private readonly scriptPath: string,
private readonly timeoutMs: number = 30000,
private readonly abortSignal?: AbortSignal,
private readonly cwd?: string,
) {
super()
}
/**
* Execute the hook script with the given JSON input
* @param inputJson The JSON string to pass to the hook via stdin
*/
async run(inputJson: string): Promise<void> {
// Wrap in try/finally to guarantee cleanup even if errors occur
try {
return await new Promise((resolve, reject) => {
// Register this process for tracking
HookProcessRegistry.register(this)
this.isRegistered = true
// Check if already aborted
if (this.abortSignal?.aborted) {
this.safeUnregister()
reject(new Error("Hook execution cancelled"))
return
}
// Set up abort handler
const abortHandler = () => {
if (this.childProcess && !this.isCompleted) {
this.isCompleted = true // Mark as completed immediately
// Remove abort listener immediately to prevent double-rejection
if (this.abortSignal) {
this.abortSignal.removeEventListener("abort", abortHandler)
}
// Clean up execution timeout timer
if (this.timeoutHandle) {
clearTimeout(this.timeoutHandle)
this.timeoutHandle = null
}
// Unregister from active processes
this.safeUnregister()
// Kill the process (async, fire-and-forget)
if (this.childProcess.pid) {
this.childProcess.kill("SIGTERM")
}
// Reject immediately - don't wait for process to die
reject(new Error("Hook execution cancelled by user"))
}
}
if (this.abortSignal) {
this.abortSignal.addEventListener("abort", abortHandler, { once: true })
}
// Windows executes hooks with PowerShell directly.
// Unix executes hook files through the shell for shebang support.
void (async () => {
try {
const launchConfig = await getHookLaunchConfig(this.scriptPath)
this.childProcess = spawn(launchConfig.command, launchConfig.args, {
stdio: ["pipe", "pipe", "pipe"],
shell: launchConfig.shell,
detached: launchConfig.detached,
cwd: this.cwd, // Execute from the determined workspace root
windowsHide: true,
})
let didEmitEmptyLine = false
// Set up timeout
this.timeoutHandle = setTimeout(() => {
if (this.childProcess && !this.isCompleted) {
this.childProcess.kill("SIGTERM")
reject(
new Error(
`Hook execution timed out after ${this.timeoutMs}ms. The hook script at '${this.scriptPath}' took too long to complete.`,
),
)
}
}, this.timeoutMs)
// Handle stdout
this.childProcess.stdout?.on("data", (data) => {
const output = data.toString()
this.stdoutBuffer += output
this.handleOutput(output, didEmitEmptyLine, "stdout")
if (!didEmitEmptyLine && output) {
this.emit("line", "", "stdout") // Signal start of output
didEmitEmptyLine = true
}
})
// Handle stderr
this.childProcess.stderr?.on("data", (data) => {
const output = data.toString()
this.stderrBuffer += output
this.handleOutput(output, didEmitEmptyLine, "stderr")
if (!didEmitEmptyLine && output) {
this.emit("line", "", "stderr") // Signal start of output
didEmitEmptyLine = true
}
})
// Handle process completion
this.childProcess.on("close", (code, signal) => {
this.exitCode = code
this.isCompleted = true
this.emitRemainingBuffer()
// Unregister from active processes
this.safeUnregister()
// Clear execution timeout timer
if (this.timeoutHandle) {
clearTimeout(this.timeoutHandle)
this.timeoutHandle = null
}
// Remove abort listener
if (this.abortSignal) {
this.abortSignal.removeEventListener("abort", abortHandler)
}
this.emit("completed", code, signal)
if (code === 0) {
resolve()
} else {
reject(new Error(`Hook exited with code ${code}${signal ? `, signal ${signal}` : ""}`))
}
})
// Handle process errors
this.childProcess.on("error", (error) => {
// Unregister from active processes
this.safeUnregister()
if (this.timeoutHandle) {
clearTimeout(this.timeoutHandle)
this.timeoutHandle = null
}
// Remove abort listener
if (this.abortSignal) {
this.abortSignal.removeEventListener("abort", abortHandler)
}
this.emit("error", error)
reject(error)
})
// Send input to the process
try {
this.childProcess.stdin?.write(inputJson)
this.childProcess.stdin?.end()
} catch (error) {
reject(new Error(`Failed to write input to hook: ${error}`))
}
} catch (error) {
this.safeUnregister()
if (this.abortSignal) {
this.abortSignal.removeEventListener("abort", abortHandler)
}
reject(error)
}
})()
})
} finally {
// Guaranteed cleanup even if process setup fails or throws
this.safeUnregister()
}
}
/**
* Safely unregister from the process registry.
* This is idempotent and prevents double-unregistration issues.
*/
private safeUnregister(): void {
if (this.isRegistered) {
HookProcessRegistry.unregister(this)
this.isRegistered = false
}
}
/**
* Handle output data and emit line events.
* Enforces 1MB total output limit to prevent memory issues.
*/
private handleOutput(data: string, _didEmitEmptyLine: boolean, stream: "stdout" | "stderr"): void {
// Check output size limit
const dataSize = Buffer.byteLength(data)
const currentTotalSize = this.stdoutSize + this.stderrSize
if (currentTotalSize + dataSize > MAX_HOOK_OUTPUT_SIZE) {
if (!this.outputTruncated) {
this.outputTruncated = true
const truncationMsg = "\n\n[Output truncated: exceeded 1MB limit]"
this.emit("line", truncationMsg, stream)
Logger.warn(`[HookProcess] Output exceeded ${MAX_HOOK_OUTPUT_SIZE} bytes, truncating`)
}
return // Drop further output
}
// Track size by stream
if (stream === "stdout") {
this.stdoutSize += dataSize
} else {
this.stderrSize += dataSize
}
// Store full output
this.fullOutput += data
// Emit lines immediately
this.emitLines(data, stream)
}
/**
* Emit complete lines from buffered output
*/
private emitLines(chunk: string, stream: "stdout" | "stderr"): void {
this.buffer += chunk
let lineEndIndex
while ((lineEndIndex = this.buffer.indexOf("\n")) !== -1) {
const line = this.buffer.slice(0, lineEndIndex).trimEnd()
this.emit("line", line, stream)
this.buffer = this.buffer.slice(lineEndIndex + 1)
}
this.lastRetrievedIndex = this.fullOutput.length - this.buffer.length
}
/**
* Emit any remaining buffered output when process completes
*/
private emitRemainingBuffer(): void {
if (this.buffer) {
const remainingBuffer = this.buffer.trimEnd()
if (remainingBuffer) {
// Determine which stream this came from based on content
// This is a fallback; in practice, line events should capture most output
this.emit("line", remainingBuffer, "stdout")
}
this.buffer = ""
this.lastRetrievedIndex = this.fullOutput.length
}
}
/**
* Get unretrieved output (for compatibility with terminal process interface)
*/
getUnretrievedOutput(): string {
const unretrieved = this.fullOutput.slice(this.lastRetrievedIndex)
this.lastRetrievedIndex = this.fullOutput.length
return unretrieved.trimEnd()
}
/**
* Get the complete stdout buffer (for JSON parsing)
*/
getStdout(): string {
return this.stdoutBuffer
}
/**
* Get the complete stderr buffer (for error reporting)
*/
getStderr(): string {
return this.stderrBuffer
}
/**
* Get the exit code
*/
getExitCode(): number | null {
return this.exitCode
}
/**
* Check if process has completed
*/
hasCompleted(): boolean {
return this.isCompleted
}
/**
* Terminate the process and its entire process tree.
* Uses process groups on Unix to kill child processes.
* Implements graceful shutdown with 2-second timeout before force kill.
*/
async terminate(): Promise<void> {
if (!this.childProcess || this.isCompleted) {
// Still ensure unregistration even if process already completed
this.safeUnregister()
return
}
const pid = this.childProcess.pid
if (!pid) {
return
}
try {
// On Unix, kill process group (negative PID kills all children)
// On Windows, just kill the process (tree-kill would be better but adds dependency)
if (process.platform !== "win32") {
// Kill process group with SIGTERM for graceful shutdown
process.kill(-pid, "SIGTERM")
} else {
// On Windows, just kill the process
this.childProcess.kill("SIGTERM")
}
// Wait up to 2 seconds for graceful shutdown
const gracefulTimeout = new Promise((resolve) => setTimeout(resolve, 2000))
const processExit = new Promise((resolve) => {
this.childProcess?.once("exit", resolve)
})
await Promise.race([processExit, gracefulTimeout])
// Force kill if still running
if (!this.isCompleted) {
if (process.platform !== "win32") {
process.kill(-pid, "SIGKILL")
} else {
this.childProcess?.kill("SIGKILL")
}
}
} catch (error) {
// Process might already be dead, which is fine
Logger.debug(`[HookProcess] Error during termination: ${error}`)
} finally {
// Clear timeout regardless
if (this.timeoutHandle) {
clearTimeout(this.timeoutHandle)
this.timeoutHandle = null
}
// Ensure unregistration even if termination fails
this.safeUnregister()
}
}
}
@@ -1,64 +0,0 @@
import { Logger } from "@/shared/services/Logger"
import { HookProcess } from "./HookProcess"
/**
* Global registry for tracking active hook processes.
*
* Purpose:
* - Prevents zombie processes by tracking all running hooks
* - Enables cleanup on extension deactivation
* - Provides visibility into active hook executions
*
* Usage:
* - HookProcess automatically registers/unregisters itself
* - Extension deactivation calls terminateAll()
* - Can query active count for monitoring/debugging
*/
export class HookProcessRegistry {
private static activeProcesses = new Set<HookProcess>()
/**
* Register a hook process as active.
* Called by HookProcess when execution starts.
*/
static register(process: HookProcess): void {
HookProcessRegistry.activeProcesses.add(process)
}
/**
* Unregister a hook process (completed or failed).
* Called by HookProcess when execution ends.
*/
static unregister(process: HookProcess): void {
HookProcessRegistry.activeProcesses.delete(process)
}
/**
* Terminate all active hook processes.
* Called during extension deactivation to prevent zombie processes.
*/
static async terminateAll(): Promise<void> {
const processes = Array.from(HookProcessRegistry.activeProcesses)
if (processes.length > 0) {
Logger.log(`[HookProcessRegistry] Terminating ${processes.length} active hook process(es)`)
await Promise.all(processes.map((p) => p.terminate()))
HookProcessRegistry.activeProcesses.clear()
}
}
/**
* Get the number of currently active hook processes.
* Useful for monitoring and debugging.
*/
static getActiveCount(): number {
return HookProcessRegistry.activeProcesses.size
}
/**
* Clear the registry (for testing only).
* @internal
*/
static resetForTesting(): void {
HookProcessRegistry.activeProcesses.clear()
}
}
@@ -1,175 +0,0 @@
# Hook Test Fixtures
This directory contains pre-written hook scripts for testing the Cline hooks system.
## Directory Structure
```
fixtures/
├── hooks/
│ ├── pretooluse/ # PreToolUse hook fixtures
│ │ ├── success/ # Returns success immediately
│ │ ├── blocking/ # Blocks tool execution
│ │ ├── context-injection/ # Adds context with type prefix
│ │ └── error/ # Exits with error code
│ ├── posttooluse/ # PostToolUse hook fixtures
│ │ ├── success/ # Returns success immediately
│ │ └── error/ # Exits with error code
│ └── template/ # Template for new hooks
└── inputs/ # Sample input data (future)
```
## Using Fixtures in Tests
### With loadFixture()
The `loadFixture()` helper function copies a fixture to your test environment:
```typescript
import { loadFixture } from '../test-utils'
it("should work with real hook", async () => {
const { getEnv } = setupHookTests()
await loadFixture("hooks/pretooluse/success", getEnv().tempDir)
const factory = new HookFactory()
const runner = await factory.create("PreToolUse")
const result = await runner.run(buildPreToolUseInput({ toolName: "test_tool" }))
result.cancel.should.be.false()
})
```
### Direct File Copy
For more control, you can also manually copy fixture files.
## Available Fixtures
### PreToolUse Hooks
#### `hooks/pretooluse/success`
- **Returns**: `{ cancel: false, contextModification: "PreToolUse hook executed successfully", errorMessage: "" }`
- **Use for**: Testing happy path scenarios
#### `hooks/pretooluse/blocking`
- **Returns**: `{ cancel: true, contextModification: "", errorMessage: "Tool execution blocked by hook" }`
- **Use for**: Testing tool execution blocking
#### `hooks/pretooluse/context-injection`
- **Returns**: `{ cancel: false, contextModification: "WORKSPACE_RULES: Tool [toolName] requires review", errorMessage: "" }`
- **Use for**: Testing context injection with type prefixes
- **Note**: Dynamically includes tool name from input
#### `hooks/pretooluse/error`
- **Behavior**: Prints error to stderr and exits with code 1
- **Use for**: Testing error handling
### PostToolUse Hooks
#### `hooks/posttooluse/success`
- **Returns**: `{ cancel: false, contextModification: "PostToolUse hook executed successfully", errorMessage: "" }`
- **Use for**: Testing PostToolUse execution
#### `hooks/posttooluse/error`
- **Behavior**: Prints error to stderr and exits with code 1
- **Use for**: Testing error handling in PostToolUse
### UserPromptSubmit Hooks
#### `hooks/userpromptsubmit/success`
- **Returns**: `{ cancel: false, contextModification: "Prompt approved", errorMessage: "" }`
- **Use for**: Testing successful prompt submission
#### `hooks/userpromptsubmit/blocking`
- **Returns**: `{ cancel: true, contextModification: "", errorMessage: "Prompt violates policy" }`
- **Use for**: Testing prompt submission blocking
#### `hooks/userpromptsubmit/context-injection`
- **Returns**: `{ cancel: false, contextModification: "CONTEXT_INJECTION: User is in plan mode", errorMessage: "" }`
- **Use for**: Testing context injection into task request
#### `hooks/userpromptsubmit/multiline`
- **Returns**: `{ cancel: false, contextModification: "Line count: N", errorMessage: "" }`
- **Use for**: Testing multiline prompt handling
- **Note**: Dynamically counts newlines in the prompt
#### `hooks/userpromptsubmit/large-prompt`
- **Returns**: `{ cancel: false, contextModification: "Prompt size: N", errorMessage: "" }`
- **Use for**: Testing large prompt handling
- **Note**: Dynamically reports prompt character count
#### `hooks/userpromptsubmit/special-chars`
- **Returns**: `{ cancel: false, contextModification: "Special chars preserved" | "Missing special chars", errorMessage: "" }`
- **Use for**: Testing special character preservation
- **Note**: Checks for @, #, and $ characters
#### `hooks/userpromptsubmit/empty-prompt`
- **Returns**: `{ cancel: false, contextModification: "Prompt length: 0", errorMessage: "" }`
- **Use for**: Testing empty prompt handling
- **Note**: Safely handles undefined or empty prompts
#### `hooks/userpromptsubmit/malformed-json`
- **Behavior**: Outputs invalid JSON ("not valid json")
- **Use for**: Testing malformed JSON error handling
#### `hooks/userpromptsubmit/error`
- **Behavior**: Prints error to stderr and exits with code 1
- **Use for**: Testing error handling in UserPromptSubmit
### TaskStart Hooks
#### `hooks/taskstart/success`
- **Returns**: `{ cancel: false, contextModification: "TaskStart hook executed successfully", errorMessage: "" }`
- **Use for**: Testing TaskStart hook success path, allowing task to proceed
#### `hooks/taskstart/blocking`
- **Returns**: `{ cancel: true, contextModification: "", errorMessage: "Task execution blocked by hook" }`
- **Use for**: Testing task blocking at start (e.g., policy enforcement)
#### `hooks/taskstart/error`
- **Behavior**: Prints error to stderr and exits with code 1
- **Use for**: Testing error handling in TaskStart hooks
## Platform Considerations
Hooks run cross-platform, but runtime differs by OS:
- **Linux/macOS**: executable hook files run directly (shebang/executable bit)
- **Windows**: hooks execute through PowerShell; tests may use a small PowerShell bridge script that pipes stdin to a Node companion file
### Creating New Fixtures
1. Create a new directory under the appropriate hook type
2. Add the hook script with shebang `#!/usr/bin/env node`
3. Make executable: `chmod +x HookName`
4. Update this README with the new fixture
### Example: Creating a new fixture
```bash
# Create directory
mkdir -p src/core/hooks/__tests__/fixtures/hooks/pretooluse/my-new-scenario
# Create hook script
cat > src/core/hooks/__tests__/fixtures/hooks/pretooluse/my-new-scenario/PreToolUse << 'EOF'
#!/usr/bin/env node
const input = JSON.parse(require('fs').readFileSync(0, 'utf-8'));
console.log(JSON.stringify({
cancel: false,
contextModification: "My custom context",
errorMessage: ""
}));
EOF
# Make executable
chmod +x src/core/hooks/__tests__/fixtures/hooks/pretooluse/my-new-scenario/PreToolUse
```
## Maintenance
- Keep fixtures simple and focused on one scenario
- Fixtures are Node.js scripts that work across platforms
- Update this README when adding new fixtures
- Remove obsolete fixtures and update references
@@ -1,4 +0,0 @@
#!/usr/bin/env node
const input = JSON.parse(require('fs').readFileSync(0, 'utf-8'));
console.error("PostToolUse hook execution failed");
process.exit(1);
@@ -1,7 +0,0 @@
#!/usr/bin/env node
const input = JSON.parse(require('fs').readFileSync(0, 'utf-8'));
console.log(JSON.stringify({
cancel: false,
contextModification: "PostToolUse hook executed successfully",
errorMessage: ""
}));
@@ -1,7 +0,0 @@
#!/usr/bin/env node
const input = JSON.parse(require('fs').readFileSync(0, 'utf-8'));
console.log(JSON.stringify({
cancel: true,
contextModification: "",
errorMessage: "Tool execution blocked by hook"
}));
@@ -1,8 +0,0 @@
#!/usr/bin/env node
const input = JSON.parse(require('fs').readFileSync(0, 'utf-8'));
const toolName = input.preToolUse?.toolName || 'unknown';
console.log(JSON.stringify({
cancel: false,
contextModification: `WORKSPACE_RULES: Tool ${toolName} requires review`,
errorMessage: ""
}));
@@ -1,4 +0,0 @@
#!/usr/bin/env node
const input = JSON.parse(require('fs').readFileSync(0, 'utf-8'));
console.error("Hook execution failed");
process.exit(1);
@@ -1,7 +0,0 @@
#!/usr/bin/env node
const input = JSON.parse(require('fs').readFileSync(0, 'utf-8'));
console.log(JSON.stringify({
cancel: false,
contextModification: "PreToolUse hook executed successfully",
errorMessage: ""
}));
@@ -1,5 +0,0 @@
#!/usr/bin/env node
// Note: For TaskCancel, contextModification is completely ignored.
console.error("Hook execution error");
process.exit(1);
@@ -1,7 +0,0 @@
#!/usr/bin/env node
// Note: For TaskCancel, contextModification is completely ignored.
console.log(JSON.stringify({
cancel: true,
errorMessage: ""
}));
@@ -1,7 +0,0 @@
#!/usr/bin/env node
// Note: For TaskCancel, contextModification is completely ignored.
console.log(JSON.stringify({
cancel: true,
errorMessage: "some error happened"
}));
@@ -1,7 +0,0 @@
#!/usr/bin/env node
// Note: For TaskCancel, contextModification is completely ignored.
console.log(JSON.stringify({
cancel: false,
errorMessage: ""
}));
@@ -1,7 +0,0 @@
#!/usr/bin/env node
// Note: For TaskCancel, contextModification is completely ignored.
console.log(JSON.stringify({
cancel: false,
errorMessage: "some error happened"
}));
@@ -1,7 +0,0 @@
#!/usr/bin/env node
const input = JSON.parse(require('fs').readFileSync(0, 'utf-8'));
console.log(JSON.stringify({
cancel: false,
contextModification: "COMPLETED: " + input.taskComplete.taskMetadata.result,
errorMessage: ""
}));
@@ -1,3 +0,0 @@
#!/usr/bin/env node
console.error("Hook execution error");
process.exit(1);
@@ -1,6 +0,0 @@
#!/usr/bin/env node
console.log(JSON.stringify({
cancel: false,
contextModification: "TaskComplete hook executed successfully",
errorMessage: ""
}));
@@ -1,11 +0,0 @@
#!/usr/bin/env node
const input = JSON.parse(require('fs').readFileSync(0, 'utf-8'));
const deleted = input.taskResume?.previousState?.conversationHistoryDeleted === 'true';
console.log(JSON.stringify({
cancel: false,
contextModification: deleted
? "TASK_CONTEXT: Some conversation history was truncated due to context window limits"
: "",
errorMessage: ""
}));
@@ -1,9 +0,0 @@
#!/usr/bin/env node
const input = JSON.parse(require('fs').readFileSync(0, 'utf-8'));
const taskId = input.taskResume?.taskMetadata?.taskId || 'unknown';
console.log(JSON.stringify({
cancel: false,
contextModification: `WORKSPACE_RULES: Task ${taskId} resumed - review previous context`,
errorMessage: ""
}));
@@ -1,3 +0,0 @@
#!/usr/bin/env node
console.error("TaskResume hook encountered an error");
process.exit(1);
@@ -1,13 +0,0 @@
#!/usr/bin/env node
const input = JSON.parse(require('fs').readFileSync(0, 'utf-8'));
const lastMessageTs = parseInt(input.taskResume?.previousState?.lastMessageTs || '0');
const now = Date.now();
const hoursAgo = Math.floor((now - lastMessageTs) / 3600000);
console.log(JSON.stringify({
cancel: false,
contextModification: hoursAgo >= 1
? `TASK_CONTEXT: Task was paused ${hoursAgo} hours ago - you may need to re-familiarize yourself with the context`
: "",
errorMessage: ""
}));
@@ -1,9 +0,0 @@
#!/usr/bin/env node
const input = JSON.parse(require('fs').readFileSync(0, 'utf-8'));
const messageCount = parseInt(input.taskResume?.previousState?.messageCount || '0');
console.log(JSON.stringify({
cancel: false,
contextModification: `TASK_CONTEXT: Resuming task with ${messageCount} previous messages`,
errorMessage: ""
}));
@@ -1,13 +0,0 @@
#!/usr/bin/env node
const input = JSON.parse(require('fs').readFileSync(0, 'utf-8'));
const lastMessageTs = parseInt(input.taskResume?.previousState?.lastMessageTs || '0');
const now = Date.now();
const minutesAgo = Math.floor((now - lastMessageTs) / 60000);
console.log(JSON.stringify({
cancel: false,
contextModification: minutesAgo < 5
? "TASK_CONTEXT: Recently paused task - context is still fresh"
: "",
errorMessage: ""
}));
@@ -1,8 +0,0 @@
#!/usr/bin/env node
const input = JSON.parse(require('fs').readFileSync(0, 'utf-8'));
console.log(JSON.stringify({
cancel: false,
contextModification: "TaskResume hook executed successfully",
errorMessage: ""
}));
@@ -1,8 +0,0 @@
#!/usr/bin/env node
const input = JSON.parse(require('fs').readFileSync(0, 'utf-8'));
console.log(JSON.stringify({
cancel: true,
contextModification: "",
errorMessage: "Task execution blocked by hook"
}));
@@ -1,3 +0,0 @@
#!/usr/bin/env node
console.error("Hook execution error");
process.exit(1);
@@ -1,8 +0,0 @@
#!/usr/bin/env node
const input = JSON.parse(require('fs').readFileSync(0, 'utf-8'));
console.log(JSON.stringify({
cancel: false,
contextModification: "TaskStart hook executed successfully",
errorMessage: ""
}));
@@ -1,7 +0,0 @@
#!/usr/bin/env node
const input = JSON.parse(require('fs').readFileSync(0, 'utf-8'));
console.log(JSON.stringify({
cancel: true,
contextModification: "",
errorMessage: "Prompt violates policy"
}));
@@ -1,7 +0,0 @@
#!/usr/bin/env node
const input = JSON.parse(require('fs').readFileSync(0, 'utf-8'));
console.log(JSON.stringify({
cancel: false,
contextModification: "CONTEXT_INJECTION: User is in plan mode",
errorMessage: ""
}));
@@ -1,8 +0,0 @@
#!/usr/bin/env node
const input = JSON.parse(require('fs').readFileSync(0, 'utf-8'));
const promptLength = typeof input.userPromptSubmit.prompt === 'string' ? input.userPromptSubmit.prompt.length : 0;
console.log(JSON.stringify({
cancel: false,
contextModification: "Prompt length: " + promptLength,
errorMessage: ""
}));
@@ -1,3 +0,0 @@
#!/usr/bin/env node
console.error("Hook execution error");
process.exit(1);
@@ -1,8 +0,0 @@
#!/usr/bin/env node
const input = JSON.parse(require('fs').readFileSync(0, 'utf-8'));
const size = input.userPromptSubmit.prompt.length;
console.log(JSON.stringify({
cancel: false,
contextModification: "Prompt size: " + size,
errorMessage: ""
}));
@@ -1,2 +0,0 @@
#!/usr/bin/env node
console.log("not valid json");
@@ -1,8 +0,0 @@
#!/usr/bin/env node
const input = JSON.parse(require('fs').readFileSync(0, 'utf-8'));
const lineCount = (input.userPromptSubmit.prompt.match(/\n/g) || []).length + 1;
console.log(JSON.stringify({
cancel: false,
contextModification: "Line count: " + lineCount,
errorMessage: ""
}));
@@ -1,9 +0,0 @@
#!/usr/bin/env node
const input = JSON.parse(require('fs').readFileSync(0, 'utf-8'));
const prompt = input.userPromptSubmit.prompt;
const hasSpecialChars = prompt.includes("@") && prompt.includes("#") && prompt.includes("$");
console.log(JSON.stringify({
cancel: false,
contextModification: hasSpecialChars ? "Special chars preserved" : "Missing special chars",
errorMessage: ""
}));
@@ -1,7 +0,0 @@
#!/usr/bin/env node
const input = JSON.parse(require('fs').readFileSync(0, 'utf-8'));
console.log(JSON.stringify({
cancel: false,
contextModification: "Prompt approved",
errorMessage: ""
}));
@@ -1,66 +0,0 @@
#!/usr/bin/env node
/**
* TEMPLATE HOOK SCRIPT
*
* This is a template for creating new hook fixtures.
* Copy this file to create a new fixture script.
*
* Customize the logic below to implement your specific hook behavior.
*/
try {
// Parse the input from stdin (what gets passed to the hook)
const input = JSON.parse(require('fs').readFileSync(0, 'utf-8'));
// Extract relevant input data
// For PreToolUse hooks:
const { toolName, parameters } = input.preToolUse || {};
// For PostToolUse hooks:
// const { toolName, parameters, result, success, executionTimeMs } = input.postToolUse || {};
// Common metadata (available in all hook types)
const { hookName: hookType, timestamp, taskId, workspaceRoots, userId } = input;
// Initialize output variables
let shouldContinue = true;
let contextModification = "";
let errorMessage = "";
// === CUSTOMIZE THIS LOGIC ===
// Implement your hook logic here
// Example: Simple success hook
contextModification = "TEMPLATE: Hook executed successfully";
// Example: Context injection based on tool name
if (toolName === "write_to_file") {
contextModification = "FILE_OPERATIONS: File modification operation";
} else if (toolName === "run_command") {
contextModification = "SYSTEM_OPERATIONS: Command execution operation";
}
// Example: Validation/blocking
// if (!parameters?.path) {
// shouldContinue = false;
// errorMessage = "ERROR: Tool requires a 'path' parameter";
// }
// === END CUSTOM LOGIC ===
// Return the standardized output format
console.log(JSON.stringify({
shouldContinue,
contextModification,
errorMessage
}));
} catch (error) {
// Error handling - hooks should handle their own errors gracefully
const errorMessage = error instanceof Error ? error.message : String(error);
console.log(JSON.stringify({
cancel: true,
contextModification: "",
errorMessage: `HOOK_ERROR: ${errorMessage}`
}));
}
@@ -1,96 +0,0 @@
# Hook Template for New Fixtures
This directory contains a template for creating new hook fixtures. When adding a new hook fixture, copy from this template and customize as needed.
## Files in This Template
- `HookName` - Hook script template (executable Node.js script)
- `README.md` - This file
## How to Create a New Fixture
### Step 1: Choose the Scenario Type
Decide what your hook fixture should test:
- `success` - Returns success immediately
- `blocking` - Blocks tool execution
- `context-injection` - Adds context information
- `error` - Exits with error code
### Step 2: Create the Directory Structure
```bash
# Example for a new PreToolUse validation fixture
mkdir -p src/core/hooks/__tests__/fixtures/hooks/pretooluse/validation/
# Copy template file
cp src/core/hooks/__tests__/fixtures/template/HookName src/core/hooks/__tests__/fixtures/hooks/pretooluse/validation/PreToolUse
# Make executable
chmod +x src/core/hooks/__tests__/fixtures/hooks/pretooluse/validation/PreToolUse
```
### Step 3: Customize the Hook Script
Edit the new fixture file to implement your specific logic:
```javascript
#!/usr/bin/env node
const input = JSON.parse(require('fs').readFileSync(0, 'utf-8'));
// Extract relevant data
const { toolName, parameters } = input.preToolUse;
let shouldContinue = true;
let contextModification = "";
let errorMessage = "";
// Your custom logic here
if (!parameters || !parameters.path) {
shouldContinue = false;
errorMessage = "ERROR: Tool requires a 'path' parameter";
} else {
contextModification = "VALIDATION: Basic input validation passed";
}
// Return standardized output
console.log(JSON.stringify({
shouldContinue,
contextModification,
errorMessage
}));
```
### Step 4: Update Documentation
Add your new fixture to `fixtures/README.md` with:
- Fixture path
- What it returns
- What it's used for testing
- Any special behavior notes
## Best Practices
### Keep Fixtures Focused
- Test one specific scenario per fixture
- Use simple, easy-to-understand logic
- Document complex behavior with comments
### Platform Compatibility
- Write portable Node.js code
- These fixtures work via embedded shell (like git hooks)
- Avoid platform-specific logic
### Naming Conventions
- Use UPPERCASE for context type prefixes (e.g., `WORKSPACE_RULES:`, `FILE_OPERATIONS:`)
- Be descriptive about what the fixture tests
- Follow existing naming patterns in other fixtures
## Examples from Existing Fixtures
See the existing fixtures for real-world examples:
- `../hooks/pretooluse/success/` - Simple success case
- `../hooks/pretooluse/blocking/` - How to block execution
- `../hooks/pretooluse/context-injection/` - How to inject context
- `../hooks/pretooluse/error/` - How to return errors
@@ -1,689 +0,0 @@
import { afterEach, beforeEach, describe, it } from "bun:test"
import "should"
import fs from "fs/promises"
import path from "path"
import sinon from "sinon"
import { setDistinctId } from "@/services/logging/distinctId"
import { HookFactory } from "../hook-factory"
import { createHookTestEnv, HookTestEnv, stubHookDirs, withPlatform, writeHookScriptForPlatform } from "./test-utils"
describe("Hook System", () => {
let tempDir: string
let sandbox: sinon.SinonSandbox
let hookTestEnv: HookTestEnv
const WINDOWS_HOOK_TEST_TIMEOUT_MS = 15000
const WINDOWS_TEST_TIMEOUT_MS = 10000
// Helper to write executable hook script
const writeHookScript = async (hookPath: string, nodeScript: string): Promise<void> => {
await writeHookScriptForPlatform(hookPath, nodeScript)
}
beforeEach(async () => {
if (process.platform === "win32") {
}
setDistinctId("test-id")
hookTestEnv = await createHookTestEnv()
tempDir = hookTestEnv.tempDir
sandbox = hookTestEnv.sandbox
})
afterEach(async () => {
await hookTestEnv.cleanup()
})
describe("NoOpRunner", () => {
it("should return success without executing anything when no hooks found", async () => {
const factory = new HookFactory()
const runner = await factory.create("PreToolUse")
const result = await runner.run({
taskId: "test-task",
preToolUse: {
toolName: "test_tool",
parameters: {},
},
})
result.cancel.should.be.false()
;(result.contextModification === undefined || result.contextModification === "").should.be.true()
})
})
describe("StdioHookRunner", () => {
it(
"should execute workspace hook from its respective workspace root directory",
async () => {
if (process.platform === "win32") {
}
// Create a test hook script that outputs the current working directory
const hookPath = path.join(tempDir, ".clinerules", "hooks", "PreToolUse")
const hookScript = `#!/usr/bin/env node
const input = require('fs').readFileSync(0, 'utf-8');
// Output the current working directory
console.log(JSON.stringify({
cancel: false,
contextModification: "CWD: " + process.cwd()
}))`
await writeHookScript(hookPath, hookScript)
// Test execution
const factory = new HookFactory()
const runner = await factory.create("PreToolUse")
const result = await runner.run({
taskId: "test-task",
preToolUse: {
toolName: "test_tool",
parameters: {},
},
})
result.cancel.should.be.false()
// The hook should execute from its workspace root (tempDir)
// Use fs.realpath to normalize paths (handles macOS /private prefix)
const cwdFromHook = result.contextModification?.replace("CWD: ", "")
const normalizedCwd = await fs.realpath(cwdFromHook)
const normalizedTempDir = await fs.realpath(tempDir)
normalizedCwd.should.equal(normalizedTempDir)
},
WINDOWS_HOOK_TEST_TIMEOUT_MS,
)
it("should execute hook script and parse output", async () => {
// Create a test hook script
const hookPath = path.join(tempDir, ".clinerules", "hooks", "PreToolUse")
const hookScript = `#!/usr/bin/env node
const input = require('fs').readFileSync(0, 'utf-8');
console.log(JSON.stringify({
cancel: false,
contextModification: "TEST_CONTEXT: Added by hook"
}))`
await writeHookScript(hookPath, hookScript)
// Test execution
const factory = new HookFactory()
const runner = await factory.create("PreToolUse")
const result = await runner.run({
taskId: "test-task",
preToolUse: {
toolName: "test_tool",
parameters: {},
},
})
result.cancel.should.be.false()
result.contextModification?.should.equal("TEST_CONTEXT: Added by hook")
})
it("should handle script that blocks execution", async () => {
const hookPath = path.join(tempDir, ".clinerules", "hooks", "PreToolUse")
const hookScript = `#!/usr/bin/env node
console.log(JSON.stringify({
cancel: true,
errorMessage: "Hook blocked execution"
}))`
await writeHookScript(hookPath, hookScript)
const factory = new HookFactory()
const runner = await factory.create("PreToolUse")
const result = await runner.run({
taskId: "test-task",
preToolUse: {
toolName: "test_tool",
parameters: {},
},
})
result.cancel.should.be.true()
result.errorMessage?.should.equal("Hook blocked execution")
})
it("should truncate large context modifications", async () => {
const hookPath = path.join(tempDir, ".clinerules", "hooks", "PreToolUse")
// Create context larger than 50KB
const largeContext = "x".repeat(60000)
const hookScript = `#!/usr/bin/env node
console.log(JSON.stringify({
cancel: false,
contextModification: "${largeContext}"
}))`
await writeHookScript(hookPath, hookScript)
const factory = new HookFactory()
const runner = await factory.create("PreToolUse")
const result = await runner.run({
taskId: "test-task",
preToolUse: {
toolName: "test_tool",
parameters: {},
},
})
result.contextModification?.length.should.be.lessThan(60000)
result.contextModification?.should.match(/truncated due to size limit/)
})
it("should handle script errors", async () => {
const hookPath = path.join(tempDir, ".clinerules", "hooks", "PreToolUse")
const hookScript = `#!/usr/bin/env node
process.exit(1)`
await writeHookScript(hookPath, hookScript)
const factory = new HookFactory()
const runner = await factory.create("PreToolUse")
try {
await runner.run({
taskId: "test-task",
preToolUse: {
toolName: "test_tool",
parameters: {},
},
})
throw new Error("Should have thrown")
} catch (error: any) {
error.message.should.match(/exited with code 1/)
}
})
it("should handle malformed JSON output", async () => {
const hookPath = path.join(tempDir, ".clinerules", "hooks", "PreToolUse")
const hookScript = `#!/usr/bin/env node
console.log("not valid json")`
await writeHookScript(hookPath, hookScript)
const factory = new HookFactory()
const runner = await factory.create("PreToolUse")
// When hook exits 0 but has malformed JSON, it returns success without context
const result = await runner.run({
taskId: "test-task",
preToolUse: {
toolName: "test_tool",
parameters: {},
},
})
// Hook succeeded (exit 0) but couldn't parse JSON, so returns success without context
result.cancel.should.be.false()
;(result.contextModification === undefined || result.contextModification === "").should.be.true()
})
it("should pass hook input via stdin", async () => {
const hookPath = path.join(tempDir, ".clinerules", "hooks", "PreToolUse")
const hookScript = `#!/usr/bin/env node
const input = JSON.parse(require('fs').readFileSync(0, 'utf-8'));
console.log(JSON.stringify({
cancel: false,
contextModification: "Received tool: " + input.preToolUse.toolName
}))`
await writeHookScript(hookPath, hookScript)
const factory = new HookFactory()
const runner = await factory.create("PreToolUse")
const result = await runner.run({
taskId: "test-task",
preToolUse: {
toolName: "my_test_tool",
parameters: {},
},
})
result.contextModification?.should.equal("Received tool: my_test_tool")
})
})
describe("PostToolUse Hook", () => {
it("should receive execution results", async () => {
const hookPath = path.join(tempDir, ".clinerules", "hooks", "PostToolUse")
const hookScript = `#!/usr/bin/env node
const input = JSON.parse(require('fs').readFileSync(0, 'utf-8'));
console.log(JSON.stringify({
cancel: false,
contextModification: "Tool succeeded: " + input.postToolUse.success
}))`
await writeHookScript(hookPath, hookScript)
const factory = new HookFactory()
const runner = await factory.create("PostToolUse")
const result = await runner.run({
taskId: "test-task",
postToolUse: {
toolName: "test_tool",
parameters: {},
result: "success",
success: true,
executionTimeMs: 100,
},
})
result.contextModification?.should.equal("Tool succeeded: true")
})
})
describe("Hook Discovery", () => {
it("should generate Windows PowerShell bridge files with real newlines", async () => {
const hooksDir = path.join(tempDir, ".clinerules", "hooks")
const hookBasePath = path.join(hooksDir, "PreToolUse")
await withPlatform("win32", async () => {
await writeHookScript(hookBasePath, "#!/usr/bin/env node\nprocess.exit(0)")
})
const ps1Content = await fs.readFile(`${hookBasePath}.ps1`, "utf-8")
ps1Content.should.match(/\n\$scriptPath = Join-Path/)
ps1Content.should.not.match(/`n\$scriptPath/)
})
it("should resolve .ps1 hook on windows", async () => {
const hooksDir = path.join(tempDir, ".clinerules", "hooks")
const ps1Path = path.join(hooksDir, "PreToolUse.ps1")
await fs.writeFile(ps1Path, "Write-Output '{\"cancel\":false}'")
const found = await withPlatform("win32", async () => {
return await HookFactory.findHookInHooksDir("PreToolUse", hooksDir)
})
should.exist(found)
if (!found) {
throw new Error("Expected .ps1 hook to be resolved")
}
found.should.equal(ps1Path)
})
it("should ignore extensionless hook on windows and use .ps1 only", async () => {
const hooksDir = path.join(tempDir, ".clinerules", "hooks")
const extensionless = path.join(hooksDir, "PreToolUse")
const ps1Path = path.join(hooksDir, "PreToolUse.ps1")
await fs.writeFile(extensionless, "Write-Output '{\"cancel\":false}'")
await fs.writeFile(ps1Path, "Write-Output '{\"cancel\":false}'")
const found = await withPlatform("win32", async () => {
return await HookFactory.findHookInHooksDir("PreToolUse", hooksDir)
})
should.exist(found)
if (!found) {
throw new Error("Expected .ps1 hook to be resolved")
}
found.should.equal(ps1Path)
})
it("should ignore .ps1 hook on unix-like platforms", async () => {
const hooksDir = path.join(tempDir, ".clinerules", "hooks")
const ps1Path = path.join(hooksDir, "PreToolUse.ps1")
await fs.writeFile(ps1Path, "Write-Output '{\"cancel\":false}'")
const found = await withPlatform("linux", async () => {
return await HookFactory.findHookInHooksDir("PreToolUse", hooksDir)
})
should.not.exist(found)
})
it("should find executable hook", async () => {
const hookPath = path.join(tempDir, ".clinerules", "hooks", "PreToolUse")
const hookScript = `#!/usr/bin/env node
console.log(JSON.stringify({ cancel: false }))`
await fs.writeFile(hookPath, hookScript)
await fs.chmod(hookPath, 0o755)
const factory = new HookFactory()
const runner = await factory.create("PreToolUse")
// Should find and execute the hook
const result = await runner.run({
taskId: "test-task",
preToolUse: {
toolName: "test_tool",
parameters: {},
},
})
result.cancel.should.be.false()
})
it("should not find non-executable file", async () => {
const hookPath = path.join(tempDir, ".clinerules", "hooks", "PreToolUse")
const hookScript = `#!/usr/bin/env node
console.log(JSON.stringify({ cancel: false }))`
// Write but don't make executable
await fs.writeFile(hookPath, hookScript)
// Explicitly remove executable permission
await fs.chmod(hookPath, 0o644)
const factory = new HookFactory()
const runner = await factory.create("PreToolUse")
// Should return NoOpRunner
const result = await runner.run({
taskId: "test-task",
preToolUse: {
toolName: "test_tool",
parameters: {},
},
})
// NoOpRunner always returns success
result.cancel.should.be.false()
})
it("should handle missing hooks gracefully", async () => {
// No hook file created
const factory = new HookFactory()
const runner = await factory.create("PreToolUse")
// Should return NoOpRunner
const result = await runner.run({
taskId: "test-task",
preToolUse: {
toolName: "test_tool",
parameters: {},
},
})
result.cancel.should.be.false()
})
})
describe("Error Handling", () => {
it("should handle expected ENOENT errors silently", async () => {
// No hook file exists - ENOENT is expected
const factory = new HookFactory()
const runner = await factory.create("PreToolUse")
// Should not throw, returns NoOpRunner
const result = await runner.run({
taskId: "test-task",
preToolUse: {
toolName: "test_tool",
parameters: {},
},
})
result.cancel.should.be.false()
})
it("should handle hook input with all parameters", async () => {
const hookPath = path.join(tempDir, ".clinerules", "hooks", "PreToolUse")
const hookScript = `#!/usr/bin/env node
const input = JSON.parse(require('fs').readFileSync(0, 'utf-8'));
const hasConcreteModelContext = input.model?.provider === 'openai' && input.model?.slug === 'gpt-5';
const hasAllFields = input.clineVersion && input.hookName && input.timestamp &&
input.taskId && input.workspaceRoots !== undefined &&
hasConcreteModelContext;
console.log(JSON.stringify({
cancel: false,
contextModification: hasAllFields ? "All fields present" : "Missing fields"
}))`
await writeHookScript(hookPath, hookScript)
const factory = new HookFactory()
const runner = await factory.create("PreToolUse")
const result = await runner.run({
taskId: "test-task",
model: {
provider: "openai",
slug: "gpt-5",
},
preToolUse: {
toolName: "test_tool",
parameters: { key: "value" },
},
})
result.contextModification?.should.equal("All fields present")
})
})
describe("Global Hooks", () => {
let globalHooksDir: string
let workspaceHooksDir: string
beforeEach(async () => {
// Create global hooks directory
globalHooksDir = path.join(tempDir, "global-hooks")
await fs.mkdir(globalHooksDir, { recursive: true })
workspaceHooksDir = path.join(tempDir, ".clinerules", "hooks")
// Mock getAllHooksDirs with deterministic test directories only.
// Avoid calling the real implementation, which may hit OS-specific
// filesystem resolution and add timing variance in CI.
stubHookDirs(sandbox, [globalHooksDir, workspaceHooksDir])
})
it("should execute both global and workspace hooks", async () => {
// Create global hook
const globalHookPath = path.join(globalHooksDir, "PreToolUse")
const globalHookScript = `#!/usr/bin/env node
const input = require('fs').readFileSync(0, 'utf-8');
console.log(JSON.stringify({
cancel: false,
contextModification: "GLOBAL: Context added"
}))`
await writeHookScript(globalHookPath, globalHookScript)
// Create workspace hook
const workspaceHookPath = path.join(tempDir, ".clinerules", "hooks", "PreToolUse")
const workspaceHookScript = `#!/usr/bin/env node
const input = require('fs').readFileSync(0, 'utf-8');
console.log(JSON.stringify({
cancel: false,
contextModification: "WORKSPACE: Context added"
}))`
await writeHookScript(workspaceHookPath, workspaceHookScript)
// Execute
const factory = new HookFactory()
const runner = await factory.create("PreToolUse")
const result = await runner.run({
taskId: "test-task",
preToolUse: { toolName: "test_tool", parameters: {} },
})
// Both contexts should be present (order not guaranteed)
result.cancel.should.be.false()
result.contextModification?.should.match(/GLOBAL: Context added/)
result.contextModification?.should.match(/WORKSPACE: Context added/)
})
it("should block execution if global hook blocks", async () => {
// Create blocking global hook
const globalHookPath = path.join(globalHooksDir, "PreToolUse")
const globalHookScript = `#!/usr/bin/env node
console.log(JSON.stringify({
cancel: true,
errorMessage: "Global policy violation"
}))`
await writeHookScript(globalHookPath, globalHookScript)
// Create allowing workspace hook
const workspaceHookPath = path.join(tempDir, ".clinerules", "hooks", "PreToolUse")
const workspaceHookScript = `#!/usr/bin/env node
console.log(JSON.stringify({
cancel: false
}))`
await writeHookScript(workspaceHookPath, workspaceHookScript)
const factory = new HookFactory()
const runner = await factory.create("PreToolUse")
const result = await runner.run({
taskId: "test-task",
preToolUse: { toolName: "test_tool", parameters: {} },
})
result.cancel.should.be.true()
result.errorMessage?.should.match(/Global policy violation/)
})
it("should work with only global hooks (no workspace hooks)", async () => {
// Create global hook only
const globalHookPath = path.join(globalHooksDir, "PreToolUse")
const globalHookScript = `#!/usr/bin/env node
console.log(JSON.stringify({
cancel: false,
contextModification: "Global hook only"
}))`
await writeHookScript(globalHookPath, globalHookScript)
const factory = new HookFactory()
const runner = await factory.create("PreToolUse")
const result = await runner.run({
taskId: "test-task",
preToolUse: { toolName: "test_tool", parameters: {} },
})
result.cancel.should.be.false()
result.contextModification?.should.equal("Global hook only")
})
it("should block if workspace hook blocks even when global allows", async () => {
// Create allowing global hook
const globalHookPath = path.join(globalHooksDir, "PreToolUse")
const globalHookScript = `#!/usr/bin/env node
console.log(JSON.stringify({
cancel: false,
contextModification: "Global allows"
}))`
await writeHookScript(globalHookPath, globalHookScript)
// Create blocking workspace hook
const workspaceHookPath = path.join(tempDir, ".clinerules", "hooks", "PreToolUse")
const workspaceHookScript = `#!/usr/bin/env node
console.log(JSON.stringify({
cancel: true,
errorMessage: "Workspace blocks"
}))`
await writeHookScript(workspaceHookPath, workspaceHookScript)
const factory = new HookFactory()
const runner = await factory.create("PreToolUse")
const result = await runner.run({
taskId: "test-task",
preToolUse: { toolName: "test_tool", parameters: {} },
})
result.cancel.should.be.true()
result.errorMessage?.should.match(/Workspace blocks/)
// Context from global should still be included
result.contextModification?.should.match(/Global allows/)
})
it("should combine error messages from global and workspace hooks", async () => {
// Create blocking global hook
const globalHookPath = path.join(globalHooksDir, "PreToolUse")
const globalHookScript = `#!/usr/bin/env node
console.log(JSON.stringify({
cancel: true,
errorMessage: "Global error"
}))`
await writeHookScript(globalHookPath, globalHookScript)
// Create blocking workspace hook
const workspaceHookPath = path.join(tempDir, ".clinerules", "hooks", "PreToolUse")
const workspaceHookScript = `#!/usr/bin/env node
console.log(JSON.stringify({
cancel: true,
errorMessage: "Workspace error"
}))`
await writeHookScript(workspaceHookPath, workspaceHookScript)
const factory = new HookFactory()
const runner = await factory.create("PreToolUse")
const result = await runner.run({
taskId: "test-task",
preToolUse: { toolName: "test_tool", parameters: {} },
})
result.cancel.should.be.true()
result.errorMessage?.should.match(/Global error/)
result.errorMessage?.should.match(/Workspace error/)
})
it(
"should execute global hook from primary workspace root directory",
async () => {
if (process.platform === "win32") {
}
// Create a global hook script that outputs the current working directory
const globalHookPath = path.join(globalHooksDir, "PreToolUse")
const globalHookScript = `#!/usr/bin/env node
const input = require('fs').readFileSync(0, 'utf-8');
// Output the current working directory
console.log(JSON.stringify({
cancel: false,
contextModification: "CWD: " + process.cwd()
}))`
await writeHookScript(globalHookPath, globalHookScript)
// Test execution
const factory = new HookFactory()
const runner = await factory.create("PreToolUse")
const result = await runner.run({
taskId: "test-task",
preToolUse: {
toolName: "test_tool",
parameters: {},
},
})
result.cancel.should.be.false()
// Global hooks should execute from the primary workspace root (tempDir)
// Use fs.realpath to normalize paths (handles macOS /private prefix)
const cwdFromHook = result.contextModification?.replace("CWD: ", "")
const normalizedCwd = await fs.realpath(cwdFromHook)
const normalizedTempDir = await fs.realpath(tempDir)
normalizedCwd.should.equal(normalizedTempDir)
},
WINDOWS_HOOK_TEST_TIMEOUT_MS,
)
it("should work with global PostToolUse hooks", async () => {
// Create global PostToolUse hook
const globalHookPath = path.join(globalHooksDir, "PostToolUse")
const globalHookScript = `#!/usr/bin/env node
const input = JSON.parse(require('fs').readFileSync(0, 'utf-8'));
console.log(JSON.stringify({
cancel: false,
contextModification: "Global observed: " + input.postToolUse.success
}))`
await writeHookScript(globalHookPath, globalHookScript)
const factory = new HookFactory()
const runner = await factory.create("PostToolUse")
const result = await runner.run({
taskId: "test-task",
postToolUse: {
toolName: "test_tool",
parameters: {},
result: "success",
success: true,
executionTimeMs: 100,
},
})
result.contextModification?.should.equal("Global observed: true")
})
})
})
@@ -1,177 +0,0 @@
import { afterEach, beforeEach, describe, it } from "bun:test"
import "should"
import { getHookLaunchConfig, resetHookLaunchConfigCacheForTesting } from "../HookProcess"
import { withPlatform } from "./test-utils"
function createDeferred<T>() {
let resolve!: (value: T | PromiseLike<T>) => void
let reject!: (reason?: unknown) => void
const promise = new Promise<T>((res, rej) => {
resolve = res
reject = rej
})
return { promise, resolve, reject }
}
describe("HookProcess", () => {
beforeEach(() => {
resetHookLaunchConfigCacheForTesting()
})
afterEach(() => {
resetHookLaunchConfigCacheForTesting()
})
it("uses resolved PowerShell executable and expected Windows launch args", async () => {
const resolvedExecutable = "C:\\Program Files\\PowerShell\\7\\pwsh.exe"
await withPlatform("win32", async () => {
const config = await getHookLaunchConfig(
"C:\\workspace\\.clinerules\\hooks\\PreToolUse.ps1",
async () => resolvedExecutable,
)
config.command.should.equal(resolvedExecutable)
config.args.should.deepEqual([
"-NoProfile",
"-NonInteractive",
"-ExecutionPolicy",
"Bypass",
"-File",
"C:\\workspace\\.clinerules\\hooks\\PreToolUse.ps1",
])
config.shell.should.equal(false)
config.detached.should.equal(false)
})
})
it("keeps Unix launch behavior unchanged", async () => {
await withPlatform("linux", async () => {
const config = await getHookLaunchConfig("/tmp/.clinerules/hooks/PreToolUse")
config.args.should.deepEqual([])
config.shell.should.equal(true)
config.detached.should.equal(true)
})
})
it("surfaces resolver failures", async () => {
await withPlatform("win32", async () => {
try {
await getHookLaunchConfig("C:\\workspace\\.clinerules\\hooks\\PreToolUse.ps1", async () => {
throw new Error("resolver failed")
})
throw new Error("Expected getHookLaunchConfig to throw")
} catch (error: any) {
error.message.should.match(/resolver failed/)
}
})
})
it("uses PowerShell on Windows", async () => {
await withPlatform("win32", async () => {
const ps1Path = "C:\\workspace\\.clinerules\\hooks\\PreToolUse.ps1"
const resolvedExecutable = "C:\\Program Files\\PowerShell\\7\\pwsh.exe"
let resolverCallCount = 0
const config = await getHookLaunchConfig(ps1Path, async () => {
resolverCallCount += 1
return resolvedExecutable
})
config.command.should.equal(resolvedExecutable)
config.args.should.deepEqual(["-NoProfile", "-NonInteractive", "-ExecutionPolicy", "Bypass", "-File", ps1Path])
resolverCallCount.should.equal(1)
})
})
it("coalesces concurrent Windows launcher resolution into a single in-flight resolver call", async () => {
await withPlatform("win32", async () => {
const resolvedExecutable = "C:\\Program Files\\PowerShell\\7\\pwsh.exe"
const resolverGate = createDeferred<void>()
let resolverCallCount = 0
const resolver = async () => {
resolverCallCount += 1
await resolverGate.promise
return resolvedExecutable
}
const launchRequests = [
getHookLaunchConfig("C:\\workspace\\.clinerules\\hooks\\PreToolUse.ps1", resolver),
getHookLaunchConfig("C:\\workspace\\.clinerules\\hooks\\PostToolUse.ps1", resolver),
getHookLaunchConfig("C:\\workspace\\.clinerules\\hooks\\TaskResume.ps1", resolver),
]
await Promise.resolve()
resolverCallCount.should.equal(1)
resolverGate.resolve()
const configs = await Promise.all(launchRequests)
resolverCallCount.should.equal(1)
configs.map((config) => config.command).should.deepEqual([resolvedExecutable, resolvedExecutable, resolvedExecutable])
configs.map((config) => config.shell).should.deepEqual([false, false, false])
})
})
it("clears failed launcher cache so later calls can recover", async () => {
await withPlatform("win32", async () => {
let resolverCallCount = 0
const flakyResolver = async () => {
resolverCallCount += 1
if (resolverCallCount === 1) {
throw new Error("initial resolver failure")
}
return "C:\\Program Files\\PowerShell\\7\\pwsh.exe"
}
try {
await getHookLaunchConfig("C:\\workspace\\.clinerules\\hooks\\PreToolUse.ps1", flakyResolver)
throw new Error("Expected first call to fail")
} catch (error: any) {
error.message.should.match(/initial resolver failure/)
}
const recoveredConfig = await getHookLaunchConfig("C:\\workspace\\.clinerules\\hooks\\PreToolUse.ps1", flakyResolver)
recoveredConfig.command.should.equal("C:\\Program Files\\PowerShell\\7\\pwsh.exe")
resolverCallCount.should.equal(2)
})
})
it("refreshes cached Windows launcher resolution after cache TTL expires", async () => {
await withPlatform("win32", async () => {
const originalDateNow = Date.now
const fakeNowValues = [1_000, 301_005]
Date.now = () => fakeNowValues.shift() ?? 301_006
let resolverCallCount = 0
const resolvedExecutables = [
"C:\\Program Files\\PowerShell\\7\\pwsh.exe",
"C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe",
]
try {
const firstConfig = await getHookLaunchConfig("C:\\workspace\\.clinerules\\hooks\\PreToolUse.ps1", async () => {
resolverCallCount += 1
return resolvedExecutables.shift() || "unexpected"
})
const secondConfig = await getHookLaunchConfig("C:\\workspace\\.clinerules\\hooks\\TaskResume.ps1", async () => {
resolverCallCount += 1
return resolvedExecutables.shift() || "unexpected"
})
firstConfig.command.should.equal("C:\\Program Files\\PowerShell\\7\\pwsh.exe")
secondConfig.command.should.equal("C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe")
resolverCallCount.should.equal(2)
} finally {
Date.now = originalDateNow
}
})
})
})
@@ -1,35 +0,0 @@
import { describe, it } from "bun:test"
import "should"
import { getHooksEnabledSafe } from "../hooks-utils"
import { withPlatform } from "./test-utils"
describe("hooks-utils", () => {
describe("getHooksEnabledSafe", () => {
it("returns false when user setting is false", () => {
getHooksEnabledSafe(false).should.be.false()
})
it("returns true when user setting is true", () => {
getHooksEnabledSafe(true).should.be.true()
})
it("is stable across repeated calls", () => {
for (let i = 0; i < 5; i++) {
getHooksEnabledSafe(true).should.be.true()
}
})
it("returns true for undefined", () => {
getHooksEnabledSafe(undefined).should.be.true()
})
it("does not depend on process.platform in current implementation", async () => {
await withPlatform("win32", () => {
getHooksEnabledSafe(true).should.be.true()
})
await withPlatform("linux", () => {
getHooksEnabledSafe(true).should.be.true()
})
})
})
})
@@ -1,117 +0,0 @@
import { afterEach, beforeEach } from "bun:test"
import * as fs from "fs/promises"
import * as os from "os"
import * as path from "path"
import sinon from "sinon"
import { StateManager } from "../../storage/StateManager"
import { createHooksDirectory } from "./test-utils"
/**
* Test environment containing temp directories and cleanup functions.
*/
export interface HookTestEnvironment {
/** Temporary directory for this test */
tempDir: string
/** Array of hooks directories (.clinerules/hooks paths) */
hooksDirs: string[]
/** Cleanup function to remove temp directories */
cleanup: () => Promise<void>
}
/**
* Creates a fresh test environment with temp directories.
* Automatically creates .clinerules/hooks structure.
*
* @returns Test environment with cleanup function
*
* @example
* const env = await createHookTestEnvironment()
* // Use env.tempDir, env.hooksDirs in tests
* await env.cleanup() // Clean up after tests
*/
export async function createHookTestEnvironment(): Promise<HookTestEnvironment> {
const tempDir = path.join(os.tmpdir(), `hook-test-${Date.now()}-${Math.random().toString(36).slice(2)}`)
await fs.mkdir(tempDir, { recursive: true })
const hooksDir = await createHooksDirectory(tempDir)
return {
tempDir,
hooksDirs: [hooksDir],
cleanup: async () => {
try {
await fs.rm(tempDir, { recursive: true, force: true })
} catch (error: any) {
// Only ignore ENOENT (already deleted), log other errors
if (error.code !== "ENOENT") {
console.warn(`Cleanup warning for ${tempDir}:`, error.message)
}
}
},
}
}
/**
* Standard setup for hook tests. Returns accessor to environment.
* Use in describe() blocks for automatic setup/teardown.
*
* @returns Object with getEnv() method to access test environment
*
* @example
* describe("My Hook Tests", () => {
* const { getEnv } = setupHookTests()
*
* it("should do something", async () => {
* const env = getEnv()
* // env.tempDir is ready to use
* })
* })
*/
export function setupHookTests(): {
getEnv: () => HookTestEnvironment
} {
let env: HookTestEnvironment
let sandbox: sinon.SinonSandbox
beforeEach(async () => {
sandbox = sinon.createSandbox()
env = await createHookTestEnvironment()
// Mock StateManager to return test workspace
mockStateManager(sandbox, [env.tempDir])
})
afterEach(async () => {
sandbox.restore()
await env.cleanup()
})
return {
getEnv: () => {
if (!env) {
throw new Error("Test environment not initialized. Called getEnv() outside of test?")
}
return env
},
}
}
/**
* Mocks StateManager to return test workspace roots.
* Useful for testing hook discovery across multiple workspace roots.
*
* @param sandbox Sinon sandbox for cleanup
* @param workspaceRoots Array of workspace root paths
*
* @example
* const sandbox = sinon.createSandbox()
* mockStateManager(sandbox, ["/path/to/workspace1", "/path/to/workspace2"])
* // StateManager.get().getGlobalStateKey("workspaceRoots") now returns mocked roots
* sandbox.restore() // Clean up after tests
*/
export function mockStateManager(sandbox: sinon.SinonSandbox, workspaceRoots: string[]): void {
sandbox.stub(StateManager, "get").returns({
getGlobalStateKey: () => workspaceRoots.map((rootPath) => ({ path: rootPath })),
} as any)
}
@@ -1,285 +0,0 @@
import { afterAll, beforeAll, describe, it } from "bun:test"
import "should"
import { escapeShellPath } from "../shell-escape"
describe("Shell Path Escaping", () => {
const originalPlatform = process.platform
// Helper to temporarily set platform
const setPlatform = (platform: NodeJS.Platform) => {
Object.defineProperty(process, "platform", {
value: platform,
writable: true,
configurable: true,
})
}
// Restore platform after tests
afterAll(() => {
Object.defineProperty(process, "platform", {
value: originalPlatform,
writable: true,
configurable: true,
})
})
describe("Unix/Linux/macOS path escaping", () => {
beforeAll(() => {
setPlatform("darwin") // macOS, but same escaping as Linux
})
it("should handle paths without special characters", () => {
const path = "/Users/user/Documents/Cline/Hooks/PreToolUse"
const escaped = escapeShellPath(path)
escaped.should.equal("'/Users/user/Documents/Cline/Hooks/PreToolUse'")
})
it("should handle paths with spaces", () => {
const path = "/Users/user/My Project/Hooks/PreToolUse"
const escaped = escapeShellPath(path)
escaped.should.equal("'/Users/user/My Project/Hooks/PreToolUse'")
})
it("should handle paths with multiple spaces", () => {
const path = "/Users/user/My Test Project/Hooks/PreToolUse"
const escaped = escapeShellPath(path)
escaped.should.equal("'/Users/user/My Test Project/Hooks/PreToolUse'")
})
it("should handle paths with single quotes", () => {
const path = "/Users/user/Test's Project/Hooks/PreToolUse"
const escaped = escapeShellPath(path)
// Single quote is escaped as '\'' (close quote, escaped quote, open quote)
escaped.should.equal("'/Users/user/Test'\\''s Project/Hooks/PreToolUse'")
})
it("should handle paths with multiple single quotes", () => {
const path = "/Users/user/Test's Project's Hooks/PreToolUse"
const escaped = escapeShellPath(path)
escaped.should.equal("'/Users/user/Test'\\''s Project'\\''s Hooks/PreToolUse'")
})
it("should handle paths with double quotes", () => {
const path = '/Users/user/Test "Quoted" Project/Hooks/PreToolUse'
const escaped = escapeShellPath(path)
// Double quotes are safe inside single quotes
escaped.should.equal("'/Users/user/Test \"Quoted\" Project/Hooks/PreToolUse'")
})
it("should handle paths with special shell characters", () => {
const path = "/Users/user/Test$Project/Hooks/PreToolUse"
const escaped = escapeShellPath(path)
// Special characters like $ are safe inside single quotes
escaped.should.equal("'/Users/user/Test$Project/Hooks/PreToolUse'")
})
it("should handle paths with backticks", () => {
const path = "/Users/user/Test`Project/Hooks/PreToolUse"
const escaped = escapeShellPath(path)
// Backticks are safe inside single quotes
escaped.should.equal("'/Users/user/Test`Project/Hooks/PreToolUse'")
})
it("should handle paths with parentheses", () => {
const path = "/Users/user/Test (Project)/Hooks/PreToolUse"
const escaped = escapeShellPath(path)
escaped.should.equal("'/Users/user/Test (Project)/Hooks/PreToolUse'")
})
it("should handle paths with ampersands", () => {
const path = "/Users/user/Test & Project/Hooks/PreToolUse"
const escaped = escapeShellPath(path)
escaped.should.equal("'/Users/user/Test & Project/Hooks/PreToolUse'")
})
it("should handle paths with semicolons", () => {
const path = "/Users/user/Test;Project/Hooks/PreToolUse"
const escaped = escapeShellPath(path)
escaped.should.equal("'/Users/user/Test;Project/Hooks/PreToolUse'")
})
it("should handle paths with pipes", () => {
const path = "/Users/user/Test|Project/Hooks/PreToolUse"
const escaped = escapeShellPath(path)
escaped.should.equal("'/Users/user/Test|Project/Hooks/PreToolUse'")
})
it("should handle global hooks directory with spaces", () => {
const path = "/Users/user name/Documents/Cline/Hooks/PreToolUse"
const escaped = escapeShellPath(path)
escaped.should.equal("'/Users/user name/Documents/Cline/Hooks/PreToolUse'")
})
it("should handle workspace hooks with spaces in root", () => {
const path = "/Users/user/My Example Project/.clinerules/hooks/PreToolUse"
const escaped = escapeShellPath(path)
escaped.should.equal("'/Users/user/My Example Project/.clinerules/hooks/PreToolUse'")
})
it("should handle paths with newlines (edge case)", () => {
const path = "/Users/user/Test\nProject/Hooks/PreToolUse"
const escaped = escapeShellPath(path)
// Newlines are safe inside single quotes
escaped.should.equal("'/Users/user/Test\nProject/Hooks/PreToolUse'")
})
it("should handle paths with tabs", () => {
const path = "/Users/user/Test\tProject/Hooks/PreToolUse"
const escaped = escapeShellPath(path)
escaped.should.equal("'/Users/user/Test\tProject/Hooks/PreToolUse'")
})
})
describe("Windows path escaping", () => {
beforeAll(() => {
setPlatform("win32")
})
it("should handle paths without special characters", () => {
const path = "C:\\Users\\user\\Documents\\Cline\\Hooks\\PreToolUse"
const escaped = escapeShellPath(path)
escaped.should.equal('"C:\\Users\\user\\Documents\\Cline\\Hooks\\PreToolUse"')
})
it("should handle paths with spaces", () => {
const path = "C:\\Users\\user\\My Project\\Hooks\\PreToolUse"
const escaped = escapeShellPath(path)
escaped.should.equal('"C:\\Users\\user\\My Project\\Hooks\\PreToolUse"')
})
it("should handle paths with multiple spaces", () => {
const path = "C:\\Users\\user\\My Test Project\\Hooks\\PreToolUse"
const escaped = escapeShellPath(path)
escaped.should.equal('"C:\\Users\\user\\My Test Project\\Hooks\\PreToolUse"')
})
it("should handle paths with double quotes", () => {
const path = 'C:\\Users\\user\\Test "Quoted" Project\\Hooks\\PreToolUse'
const escaped = escapeShellPath(path)
// Double quotes are escaped by doubling them
escaped.should.equal('"C:\\Users\\user\\Test ""Quoted"" Project\\Hooks\\PreToolUse"')
})
it("should handle paths with backslashes before quotes", () => {
const path = 'C:\\Users\\user\\Test\\"Project\\Hooks\\PreToolUse'
const escaped = escapeShellPath(path)
// Backslash before quote needs to be doubled, then quote is doubled
escaped.should.equal('"C:\\Users\\user\\Test\\\\""Project\\Hooks\\PreToolUse"')
})
it("should handle paths with single quotes", () => {
const path = "C:\\Users\\user\\Test's Project\\Hooks\\PreToolUse"
const escaped = escapeShellPath(path)
// Single quotes are safe inside double quotes on Windows
escaped.should.equal('"C:\\Users\\user\\Test\'s Project\\Hooks\\PreToolUse"')
})
it("should handle paths with special characters", () => {
const path = "C:\\Users\\user\\Test$Project\\Hooks\\PreToolUse"
const escaped = escapeShellPath(path)
// Most special characters are safe inside double quotes on Windows
escaped.should.equal('"C:\\Users\\user\\Test$Project\\Hooks\\PreToolUse"')
})
it("should handle paths with parentheses", () => {
const path = "C:\\Users\\user\\Test (Project)\\Hooks\\PreToolUse"
const escaped = escapeShellPath(path)
escaped.should.equal('"C:\\Users\\user\\Test (Project)\\Hooks\\PreToolUse"')
})
it("should handle paths with ampersands", () => {
const path = "C:\\Users\\user\\Test & Project\\Hooks\\PreToolUse"
const escaped = escapeShellPath(path)
escaped.should.equal('"C:\\Users\\user\\Test & Project\\Hooks\\PreToolUse"')
})
it("should handle global hooks directory with spaces", () => {
const path = "C:\\Users\\user name\\Documents\\Cline\\Hooks\\PreToolUse"
const escaped = escapeShellPath(path)
escaped.should.equal('"C:\\Users\\user name\\Documents\\Cline\\Hooks\\PreToolUse"')
})
it("should handle workspace hooks with spaces in root", () => {
const path = "C:\\Users\\user\\My Example Project\\.clinerules\\hooks\\PreToolUse"
const escaped = escapeShellPath(path)
escaped.should.equal('"C:\\Users\\user\\My Example Project\\.clinerules\\hooks\\PreToolUse"')
})
it("should handle UNC paths with spaces", () => {
const path = "\\\\server\\share\\My Project\\Hooks\\PreToolUse"
const escaped = escapeShellPath(path)
escaped.should.equal('"\\\\server\\share\\My Project\\Hooks\\PreToolUse"')
})
})
describe("Real-world scenarios", () => {
it("should handle typical macOS global hooks path with space in username", () => {
setPlatform("darwin")
const path = "/Users/John Doe/Documents/Cline/Hooks/PreToolUse"
const escaped = escapeShellPath(path)
escaped.should.equal("'/Users/John Doe/Documents/Cline/Hooks/PreToolUse'")
})
it("should handle typical Windows global hooks path with space in username", () => {
setPlatform("win32")
const path = "C:\\Users\\John Doe\\Documents\\Cline\\Hooks\\PreToolUse"
const escaped = escapeShellPath(path)
escaped.should.equal('"C:\\Users\\John Doe\\Documents\\Cline\\Hooks\\PreToolUse"')
})
it("should handle workspace with company name and spaces", () => {
setPlatform("darwin")
const path = "/Users/user/Projects/ACME Corp Project/.clinerules/hooks/PreToolUse"
const escaped = escapeShellPath(path)
escaped.should.equal("'/Users/user/Projects/ACME Corp Project/.clinerules/hooks/PreToolUse'")
})
it("should handle workspace with version numbers and spaces", () => {
setPlatform("darwin")
const path = "/Users/user/Projects/My Project v2.0/.clinerules/hooks/PreToolUse"
const escaped = escapeShellPath(path)
escaped.should.equal("'/Users/user/Projects/My Project v2.0/.clinerules/hooks/PreToolUse'")
})
it("should handle workspace with mixed special characters", () => {
setPlatform("darwin")
const path = "/Users/user/Projects/Test's (New) Project v2.0/.clinerules/hooks/PreToolUse"
const escaped = escapeShellPath(path)
escaped.should.equal("'/Users/user/Projects/Test'\\''s (New) Project v2.0/.clinerules/hooks/PreToolUse'")
})
})
describe("Multi-root workspace scenarios", () => {
it("should handle multiple roots with spaces (macOS)", () => {
setPlatform("darwin")
const roots = [
"/Users/user/My Frontend Project/.clinerules/hooks/PreToolUse",
"/Users/user/My Backend Project/.clinerules/hooks/PreToolUse",
"/Users/user/Shared Utils/.clinerules/hooks/PreToolUse",
]
const escaped = roots.map(escapeShellPath)
escaped.should.deepEqual([
"'/Users/user/My Frontend Project/.clinerules/hooks/PreToolUse'",
"'/Users/user/My Backend Project/.clinerules/hooks/PreToolUse'",
"'/Users/user/Shared Utils/.clinerules/hooks/PreToolUse'",
])
})
it("should handle multiple roots with spaces (Windows)", () => {
setPlatform("win32")
const roots = [
"C:\\Users\\user\\My Frontend Project\\.clinerules\\hooks\\PreToolUse",
"C:\\Users\\user\\My Backend Project\\.clinerules\\hooks\\PreToolUse",
"C:\\Users\\user\\Shared Utils\\.clinerules\\hooks\\PreToolUse",
]
const escaped = roots.map(escapeShellPath)
escaped.should.deepEqual([
'"C:\\Users\\user\\My Frontend Project\\.clinerules\\hooks\\PreToolUse"',
'"C:\\Users\\user\\My Backend Project\\.clinerules\\hooks\\PreToolUse"',
'"C:\\Users\\user\\Shared Utils\\.clinerules\\hooks\\PreToolUse"',
])
})
})
})
@@ -1,581 +0,0 @@
import { afterEach, beforeEach, describe, it } from "bun:test"
import "should"
import fs from "fs/promises"
import path from "path"
import sinon from "sinon"
import { HookFactory } from "../hook-factory"
import { createHookTestEnv, HookTestEnv, loadFixture, stubHookDirs, writeHookScriptForPlatform } from "./test-utils"
describe("TaskCancel Hook", () => {
let tempDir: string
let sandbox: sinon.SinonSandbox
let getEnv: () => { tempDir: string }
let hookTestEnv: HookTestEnv
const writeHookScript = async (hookPath: string, nodeScript: string): Promise<void> => {
await writeHookScriptForPlatform(hookPath, nodeScript)
}
beforeEach(async () => {
hookTestEnv = await createHookTestEnv()
tempDir = hookTestEnv.tempDir
sandbox = hookTestEnv.sandbox
getEnv = () => ({ tempDir })
})
afterEach(async () => {
await hookTestEnv.cleanup()
})
describe("Hook Input Format", () => {
it("should receive task metadata with completionStatus", async () => {
const hookPath = path.join(tempDir, ".clinerules", "hooks", "TaskCancel")
const hookScript = `#!/usr/bin/env node
const input = JSON.parse(require('fs').readFileSync(0, 'utf-8'));
const metadata = input.taskCancel.taskMetadata;
const hasAllFields = metadata.taskId && metadata.ulid && metadata.completionStatus;
console.log(JSON.stringify({
cancel: false,
contextModification: hasAllFields ? "Test passed" : "Missing metadata",
errorMessage: ""
}))`
await writeHookScript(hookPath, hookScript)
const factory = new HookFactory()
const runner = await factory.create("TaskCancel")
const result = await runner.run({
taskId: "test-task-id",
taskCancel: {
taskMetadata: {
taskId: "test-task-id",
ulid: "test-ulid",
completionStatus: "cancelled",
},
},
})
result.cancel.should.be.false()
// Note: contextModification is ignored for TaskCancel hooks
})
it("should handle 'abandoned' completion status", async () => {
const hookPath = path.join(tempDir, ".clinerules", "hooks", "TaskCancel")
const hookScript = `#!/usr/bin/env node
const input = JSON.parse(require('fs').readFileSync(0, 'utf-8'));
const status = input.taskCancel.taskMetadata.completionStatus;
// Verify we can read the status (for logging purposes)
if (status !== "abandoned") {
process.exit(1);
}
console.log(JSON.stringify({
cancel: false,
contextModification: "",
errorMessage: ""
}))`
await writeHookScript(hookPath, hookScript)
const factory = new HookFactory()
const runner = await factory.create("TaskCancel")
const result = await runner.run({
taskId: "test-task-id",
taskCancel: {
taskMetadata: {
taskId: "test-task-id",
ulid: "test-ulid",
completionStatus: "abandoned",
},
},
})
result.cancel.should.be.false()
// Note: contextModification is ignored for TaskCancel hooks
})
it("should receive all common hook input fields", async () => {
const hookPath = path.join(tempDir, ".clinerules", "hooks", "TaskCancel")
const hookScript = `#!/usr/bin/env node
const input = JSON.parse(require('fs').readFileSync(0, 'utf-8'));
const hasAllFields = input.clineVersion && input.hookName === 'TaskCancel' &&
input.timestamp && input.taskId &&
input.workspaceRoots !== undefined &&
input.model && input.model.provider && input.model.slug;
// Exit with error if fields are missing (for test verification)
if (!hasAllFields) {
process.exit(1);
}
console.log(JSON.stringify({
cancel: false,
contextModification: "",
errorMessage: ""
}))`
await writeHookScript(hookPath, hookScript)
const factory = new HookFactory()
const runner = await factory.create("TaskCancel")
const result = await runner.run({
taskId: "test-task-id",
taskCancel: {
taskMetadata: {
taskId: "test-task-id",
ulid: "test-ulid",
completionStatus: "cancelled",
},
},
})
result.cancel.should.be.false()
// Note: contextModification is ignored for TaskCancel hooks
})
})
describe("Fire-and-Forget Behavior", () => {
it("should ignore contextModification regardless of content", async () => {
const hookPath = path.join(tempDir, ".clinerules", "hooks", "TaskCancel")
const hookScript = `#!/usr/bin/env node
console.log(JSON.stringify({
cancel: false,
contextModification: "This is a context modification that should be ignored",
errorMessage: ""
}))`
await writeHookScript(hookPath, hookScript)
const factory = new HookFactory()
const runner = await factory.create("TaskCancel")
const result1 = await runner.run({
taskId: "test-task-id",
taskCancel: {
taskMetadata: {
taskId: "test-task-id",
ulid: "test-ulid",
completionStatus: "cancelled",
},
},
})
// Hook returns contextModification, but it's completely ignored (fire-and-forget)
result1.cancel.should.be.false()
result1.contextModification?.should.equal("This is a context modification that should be ignored")
// Update hook to return different contextModification
const hookScript2 = `#!/usr/bin/env node
console.log(JSON.stringify({
cancel: false,
contextModification: "Different context that is also ignored",
errorMessage: ""
}))`
await writeHookScript(hookPath, hookScript2)
const result2 = await runner.run({
taskId: "test-task-id",
taskCancel: {
taskMetadata: {
taskId: "test-task-id",
ulid: "test-ulid",
completionStatus: "cancelled",
},
},
})
// Both results behave identically - contextModification has no effect
result2.cancel.should.be.false()
result2.contextModification?.should.equal("Different context that is also ignored")
// The key point: both executions succeeded with cancel: false
// The contextModification value is different but behavior is identical (fire-and-forget)
})
it("should succeed regardless of hook return value", async () => {
const hookPath = path.join(tempDir, ".clinerules", "hooks", "TaskCancel")
const hookScript = `#!/usr/bin/env node
// Note: contextModification is ignored for TaskCancel hooks
console.log(JSON.stringify({
cancel: false,
contextModification: "",
errorMessage: ""
}))`
await writeHookScript(hookPath, hookScript)
const factory = new HookFactory()
const runner = await factory.create("TaskCancel")
const result = await runner.run({
taskId: "test-task-id",
taskCancel: {
taskMetadata: {
taskId: "test-task-id",
ulid: "test-ulid",
completionStatus: "cancelled",
},
},
})
// TaskCancel is fire-and-forget, so it always reports success
result.cancel.should.be.false()
})
it("should return error message when hook returns cancel: true", async () => {
const hookPath = path.join(tempDir, ".clinerules", "hooks", "TaskCancel")
const hookScript = `#!/usr/bin/env node
console.log(JSON.stringify({
cancel: true,
contextModification: "",
errorMessage: "Hook tried to block cancellation"
}))`
await writeHookScript(hookPath, hookScript)
const factory = new HookFactory()
const runner = await factory.create("TaskCancel")
const result = await runner.run({
taskId: "test-task-id",
taskCancel: {
taskMetadata: {
taskId: "test-task-id",
ulid: "test-ulid",
completionStatus: "cancelled",
},
},
})
// Hook result includes cancel: true and errorMessage
// In abortTask(), the errorMessage will be surfaced to the user via this.say("error", ...)
// but cancellation will still proceed (fire-and-forget behavior)
result.cancel.should.be.true()
result.errorMessage?.should.equal("Hook tried to block cancellation")
})
it("should execute without errors for cleanup purposes", async () => {
const hookPath = path.join(tempDir, ".clinerules", "hooks", "TaskCancel")
const hookScript = `#!/usr/bin/env node
const input = JSON.parse(require('fs').readFileSync(0, 'utf-8'));
const status = input.taskCancel.taskMetadata.completionStatus;
// Hook can perform cleanup/logging based on status
// Note: contextModification is ignored for TaskCancel hooks
console.log(JSON.stringify({
cancel: false,
contextModification: "",
errorMessage: ""
}))`
await writeHookScript(hookPath, hookScript)
const factory = new HookFactory()
const runner = await factory.create("TaskCancel")
const result = await runner.run({
taskId: "test-task-id",
taskCancel: {
taskMetadata: {
taskId: "test-task-id",
ulid: "test-ulid",
completionStatus: "cancelled",
},
},
})
result.cancel.should.be.false()
})
})
describe("Error Handling", () => {
it("should surface hook errors to the user", async () => {
const hookPath = path.join(tempDir, ".clinerules", "hooks", "TaskCancel")
const hookScript = `#!/usr/bin/env node
console.error("Hook execution error");
process.exit(1);`
await writeHookScript(hookPath, hookScript)
const factory = new HookFactory()
const runner = await factory.create("TaskCancel")
// TaskCancel hook errors should throw (they will be caught and surfaced in abortTask)
try {
await runner.run({
taskId: "test-task-id",
taskCancel: {
taskMetadata: {
taskId: "test-task-id",
ulid: "test-ulid",
completionStatus: "cancelled",
},
},
})
throw new Error("Should have thrown")
} catch (error: any) {
error.message.should.match(/TaskCancel.*exited with code 1/)
}
})
it("should handle malformed JSON output from hook", async () => {
const hookPath = path.join(tempDir, ".clinerules", "hooks", "TaskCancel")
const hookScript = `#!/usr/bin/env node
console.log("not valid json")`
await writeHookScript(hookPath, hookScript)
const factory = new HookFactory()
const runner = await factory.create("TaskCancel")
// When hook exits 0 but has malformed JSON, it returns success without context
const result = await runner.run({
taskId: "test-task-id",
taskCancel: {
taskMetadata: {
taskId: "test-task-id",
ulid: "test-ulid",
completionStatus: "cancelled",
},
},
})
// Hook succeeded (exit 0) but couldn't parse JSON, so returns success without context
result.cancel.should.be.false()
;(result.contextModification === undefined || result.contextModification === "").should.be.true()
})
})
describe("Global and Workspace Hooks", () => {
let globalHooksDir: string
let workspaceHooksDir: string
beforeEach(async () => {
// Create global hooks directory
globalHooksDir = path.join(tempDir, "global-hooks")
await fs.mkdir(globalHooksDir, { recursive: true })
workspaceHooksDir = path.join(tempDir, ".clinerules", "hooks")
// Use deterministic hook directories to avoid test flakiness.
stubHookDirs(sandbox, [globalHooksDir, workspaceHooksDir])
})
it("should execute both global and workspace TaskCancel hooks", async () => {
// Create global hook
const globalHookPath = path.join(globalHooksDir, "TaskCancel")
const globalHookScript = `#!/usr/bin/env node
// Note: contextModification is ignored for TaskCancel hooks
console.log(JSON.stringify({
cancel: false,
contextModification: "",
errorMessage: ""
}))`
await writeHookScript(globalHookPath, globalHookScript)
// Create workspace hook
const workspaceHookPath = path.join(tempDir, ".clinerules", "hooks", "TaskCancel")
const workspaceHookScript = `#!/usr/bin/env node
// Note: contextModification is ignored for TaskCancel hooks
console.log(JSON.stringify({
cancel: false,
contextModification: "",
errorMessage: ""
}))`
await writeHookScript(workspaceHookPath, workspaceHookScript)
const factory = new HookFactory()
const runner = await factory.create("TaskCancel")
const result = await runner.run({
taskId: "test-task-id",
taskCancel: {
taskMetadata: {
taskId: "test-task-id",
ulid: "test-ulid",
completionStatus: "cancelled",
},
},
})
result.cancel.should.be.false()
// Both hooks executed successfully
})
it("should execute both hooks with different completion statuses", async () => {
const globalHookPath = path.join(globalHooksDir, "TaskCancel")
const globalHookScript = `#!/usr/bin/env node
const input = JSON.parse(require('fs').readFileSync(0, 'utf-8'));
// Can perform cleanup based on completion status
// Note: contextModification is ignored for TaskCancel hooks
console.log(JSON.stringify({
cancel: false,
contextModification: "",
errorMessage: ""
}))`
await writeHookScript(globalHookPath, globalHookScript)
const workspaceHookPath = path.join(tempDir, ".clinerules", "hooks", "TaskCancel")
const workspaceHookScript = `#!/usr/bin/env node
// Note: contextModification is ignored for TaskCancel hooks
console.log(JSON.stringify({
cancel: false,
contextModification: "",
errorMessage: ""
}))`
await writeHookScript(workspaceHookPath, workspaceHookScript)
const factory = new HookFactory()
const runner = await factory.create("TaskCancel")
const result = await runner.run({
taskId: "test-task-id",
taskCancel: {
taskMetadata: {
taskId: "test-task-id",
ulid: "test-ulid",
completionStatus: "abandoned",
},
},
})
result.cancel.should.be.false()
// Both hooks executed successfully
})
})
describe("No Hook Behavior", () => {
it("should succeed when no hook exists", async () => {
const factory = new HookFactory()
const runner = await factory.create("TaskCancel")
const result = await runner.run({
taskId: "test-task-id",
taskCancel: {
taskMetadata: {
taskId: "test-task-id",
ulid: "test-ulid",
completionStatus: "cancelled",
},
},
})
result.cancel.should.be.false()
})
})
describe("Fixture-Based Tests", () => {
it("should handle cancel: true with no error message", async () => {
await loadFixture("hooks/taskcancel/false-no-error", getEnv().tempDir)
const factory = new HookFactory()
const runner = await factory.create("TaskCancel")
const result = await runner.run({
taskId: "test-task-id",
taskCancel: {
taskMetadata: {
taskId: "test-task-id",
ulid: "test-ulid",
completionStatus: "cancelled",
},
},
})
result.cancel.should.be.true()
result.errorMessage?.should.equal("")
// In abortTask(), no error is surfaced since errorMessage is empty
// Cancellation still proceeds (fire-and-forget)
})
it("should handle cancel: true with error message", async () => {
await loadFixture("hooks/taskcancel/false-with-error", getEnv().tempDir)
const factory = new HookFactory()
const runner = await factory.create("TaskCancel")
const result = await runner.run({
taskId: "test-task-id",
taskCancel: {
taskMetadata: {
taskId: "test-task-id",
ulid: "test-ulid",
completionStatus: "cancelled",
},
},
})
result.cancel.should.be.true()
result.errorMessage?.should.equal("some error happened")
// In abortTask(), the errorMessage WILL be surfaced to user via this.say("error", ...)
// Cancellation still proceeds (fire-and-forget)
})
it("should handle cancel: false with no error message", async () => {
await loadFixture("hooks/taskcancel/true-no-error", getEnv().tempDir)
const factory = new HookFactory()
const runner = await factory.create("TaskCancel")
const result = await runner.run({
taskId: "test-task-id",
taskCancel: {
taskMetadata: {
taskId: "test-task-id",
ulid: "test-ulid",
completionStatus: "cancelled",
},
},
})
result.cancel.should.be.false()
result.errorMessage?.should.equal("")
// Normal success case - no errors to surface
})
it("should handle cancel: false with error message", async () => {
await loadFixture("hooks/taskcancel/true-with-error", getEnv().tempDir)
const factory = new HookFactory()
const runner = await factory.create("TaskCancel")
const result = await runner.run({
taskId: "test-task-id",
taskCancel: {
taskMetadata: {
taskId: "test-task-id",
ulid: "test-ulid",
completionStatus: "cancelled",
},
},
})
result.cancel.should.be.false()
result.errorMessage?.should.equal("some error happened")
// In abortTask(), the errorMessage WILL be surfaced to user via this.say("error", ...)
// This is the scenario that was fixed - error messages are now displayed regardless of shouldContinue value
// Cancellation still proceeds (fire-and-forget)
})
it("should handle hook that exits with non-zero status code", async () => {
await loadFixture("hooks/taskcancel/error", getEnv().tempDir)
const factory = new HookFactory()
const runner = await factory.create("TaskCancel")
try {
await runner.run({
taskId: "test-task-id",
taskCancel: {
taskMetadata: {
taskId: "test-task-id",
ulid: "test-ulid",
completionStatus: "cancelled",
},
},
})
throw new Error("Should have thrown")
} catch (error: any) {
error.message.should.match(/TaskCancel.*exited with code 1/)
// In abortTask(), this error WILL be caught and surfaced to user via this.say("error", ...)
// Cancellation still proceeds (fire-and-forget)
}
})
})
})
@@ -1,506 +0,0 @@
import { afterEach, beforeEach, describe, it } from "bun:test"
import "should"
import fs from "fs/promises"
import path from "path"
import sinon from "sinon"
import { HookOutput } from "../../../shared/proto/cline/hooks"
import { HookFactory } from "../hook-factory"
import { createHookTestEnv, HookTestEnv, stubHookDirs, withFixtureRunner, writeHookScriptForPlatform } from "./test-utils"
describe("TaskComplete Hook", () => {
let tempDir: string
let sandbox: sinon.SinonSandbox
let getEnv: () => { tempDir: string }
let hookTestEnv: HookTestEnv
const getErrorMessage = (error: unknown): string => (error instanceof Error ? error.message : String(error))
const writeHookScript = async (hookPath: string, nodeScript: string): Promise<void> => {
await writeHookScriptForPlatform(hookPath, nodeScript)
}
beforeEach(async () => {
hookTestEnv = await createHookTestEnv()
tempDir = hookTestEnv.tempDir
sandbox = hookTestEnv.sandbox
getEnv = () => ({ tempDir })
})
afterEach(async () => {
await hookTestEnv.cleanup()
})
describe("Hook Input Format", () => {
it("should receive task metadata with result and command", async () => {
const hookPath = path.join(tempDir, ".clinerules", "hooks", "TaskComplete")
const hookScript = `#!/usr/bin/env node
const input = JSON.parse(require('fs').readFileSync(0, 'utf-8'));
const metadata = input.taskComplete.taskMetadata;
const hasAllFields = metadata.taskId && metadata.ulid && metadata.result;
console.log(JSON.stringify({
cancel: false,
contextModification: hasAllFields ? "All metadata present" : "Missing metadata",
errorMessage: ""
}))`
await writeHookScript(hookPath, hookScript)
const factory = new HookFactory()
const runner = await factory.create("TaskComplete")
const result = await runner.run({
taskId: "test-task-id",
taskComplete: {
taskMetadata: {
taskId: "test-task-id",
ulid: "test-ulid",
result: "Task completed successfully",
command: "npm start",
},
},
})
result.cancel.should.be.false()
result.contextModification?.should.equal("All metadata present")
})
it("should handle completion without command", async () => {
const hookPath = path.join(tempDir, ".clinerules", "hooks", "TaskComplete")
const hookScript = `#!/usr/bin/env node
const input = JSON.parse(require('fs').readFileSync(0, 'utf-8'));
const metadata = input.taskComplete.taskMetadata;
const command = metadata.command || "";
console.log(JSON.stringify({
cancel: false,
contextModification: "Command: '" + command + "'",
errorMessage: ""
}))`
await writeHookScript(hookPath, hookScript)
const factory = new HookFactory()
const runner = await factory.create("TaskComplete")
const result = await runner.run({
taskId: "test-task-id",
taskComplete: {
taskMetadata: {
taskId: "test-task-id",
ulid: "test-ulid",
result: "Task completed",
command: "",
},
},
})
result.cancel.should.be.false()
result.contextModification?.should.equal("Command: ''")
})
it("should receive all common hook input fields", async () => {
const hookPath = path.join(tempDir, ".clinerules", "hooks", "TaskComplete")
const hookScript = `#!/usr/bin/env node
const input = JSON.parse(require('fs').readFileSync(0, 'utf-8'));
const hasAllFields = input.clineVersion && input.hookName === 'TaskComplete' &&
input.timestamp && input.taskId &&
input.workspaceRoots !== undefined &&
input.model && input.model.provider && input.model.slug;
console.log(JSON.stringify({
cancel: false,
contextModification: hasAllFields ? "All fields present" : "Missing fields",
errorMessage: ""
}))`
await writeHookScript(hookPath, hookScript)
const factory = new HookFactory()
const runner = await factory.create("TaskComplete")
const result = await runner.run({
taskId: "test-task-id",
taskComplete: {
taskMetadata: {
taskId: "test-task-id",
ulid: "test-ulid",
result: "Test task",
command: "",
},
},
})
result.cancel.should.be.false()
result.contextModification?.should.equal("All fields present")
})
it("should receive result text for logging", async () => {
const hookPath = path.join(tempDir, ".clinerules", "hooks", "TaskComplete")
const hookScript = `#!/usr/bin/env node
const input = JSON.parse(require('fs').readFileSync(0, 'utf-8'));
const result = input.taskComplete.taskMetadata.result;
console.log(JSON.stringify({
cancel: false,
contextModification: "Result length: " + result.length,
errorMessage: ""
}))`
await writeHookScript(hookPath, hookScript)
const factory = new HookFactory()
const runner = await factory.create("TaskComplete")
const result = await runner.run({
taskId: "test-task-id",
taskComplete: {
taskMetadata: {
taskId: "test-task-id",
ulid: "test-ulid",
result: "I've successfully completed the task by implementing all required features.",
command: "",
},
},
})
result.cancel.should.be.false()
result.contextModification?.should.equal("Result length: 75")
})
})
describe("Hook Behavior", () => {
it("should execute successfully and capture context modification", async () => {
const hookPath = path.join(tempDir, ".clinerules", "hooks", "TaskComplete")
const hookScript = `#!/usr/bin/env node
console.log(JSON.stringify({
cancel: false,
contextModification: "TaskComplete hook executed successfully",
errorMessage: ""
}))`
await writeHookScript(hookPath, hookScript)
const factory = new HookFactory()
const runner = await factory.create("TaskComplete")
const result = await runner.run({
taskId: "test-task-id",
taskComplete: {
taskMetadata: {
taskId: "test-task-id",
ulid: "test-ulid",
result: "Test task",
command: "",
},
},
})
result.cancel.should.be.false()
result.contextModification?.should.equal("TaskComplete hook executed successfully")
})
it("should capture contextModification for logging even though task is complete", async () => {
const hookPath = path.join(tempDir, ".clinerules", "hooks", "TaskComplete")
const hookScript = `#!/usr/bin/env node
const input = JSON.parse(require('fs').readFileSync(0, 'utf-8'));
console.log(JSON.stringify({
cancel: false,
contextModification: "TASK_COMPLETE: Task '" + input.taskComplete.taskMetadata.taskId + "' finished",
errorMessage: ""
}))`
await writeHookScript(hookPath, hookScript)
const factory = new HookFactory()
const runner = await factory.create("TaskComplete")
const result = await runner.run({
taskId: "test-task-id",
taskComplete: {
taskMetadata: {
taskId: "test-task-id",
ulid: "test-ulid",
result: "Build a todo app",
command: "",
},
},
})
result.cancel.should.be.false()
result.contextModification?.should.equal("TASK_COMPLETE: Task 'test-task-id' finished")
})
it("should not block task completion when hook returns cancel: true", async () => {
const hookPath = path.join(tempDir, ".clinerules", "hooks", "TaskComplete")
const hookScript = `#!/usr/bin/env node
console.log(JSON.stringify({
cancel: true,
contextModification: "",
errorMessage: "Hook tried to block completion"
}))`
await writeHookScript(hookPath, hookScript)
const factory = new HookFactory()
const runner = await factory.create("TaskComplete")
const result = await runner.run({
taskId: "test-task-id",
taskComplete: {
taskMetadata: {
taskId: "test-task-id",
ulid: "test-ulid",
result: "Test task",
command: "",
},
},
})
// Hook can return cancel: true, but it's ignored (task is already complete)
// This is similar to TaskCancel behavior
result.cancel.should.be.true()
result.errorMessage?.should.equal("Hook tried to block completion")
})
})
describe("Error Handling", () => {
it("should handle hook script errors", async () => {
const hookPath = path.join(tempDir, ".clinerules", "hooks", "TaskComplete")
const hookScript = `#!/usr/bin/env node
console.error("Hook execution error");
process.exit(1);`
await writeHookScript(hookPath, hookScript)
const factory = new HookFactory()
const runner = await factory.create("TaskComplete")
try {
await runner.run({
taskId: "test-task-id",
taskComplete: {
taskMetadata: {
taskId: "test-task-id",
ulid: "test-ulid",
result: "Test task",
command: "",
},
},
})
throw new Error("Should have thrown")
} catch (error: any) {
error.message.should.match(/TaskComplete.*exited with code 1/)
}
})
it("should handle malformed JSON output from hook", async () => {
const hookPath = path.join(tempDir, ".clinerules", "hooks", "TaskComplete")
const hookScript = `#!/usr/bin/env node
console.log("not valid json")`
await writeHookScript(hookPath, hookScript)
const factory = new HookFactory()
const runner = await factory.create("TaskComplete")
// When hook exits 0 but has malformed JSON, it returns success without context
const result = await runner.run({
taskId: "test-task-id",
taskComplete: {
taskMetadata: {
taskId: "test-task-id",
ulid: "test-ulid",
result: "Test task",
command: "",
},
},
})
// Hook succeeded (exit 0) but couldn't parse JSON, so returns success without context
result.cancel.should.be.false()
;(result.contextModification === undefined || result.contextModification === "").should.be.true()
})
})
describe("Global and Workspace Hooks", () => {
let globalHooksDir: string
let workspaceHooksDir: string
beforeEach(async () => {
// Create global hooks directory
globalHooksDir = path.join(tempDir, "global-hooks")
await fs.mkdir(globalHooksDir, { recursive: true })
workspaceHooksDir = path.join(tempDir, ".clinerules", "hooks")
// Use deterministic hook directories to avoid test flakiness.
stubHookDirs(sandbox, [globalHooksDir, workspaceHooksDir])
})
it("should execute both global and workspace TaskComplete hooks", async () => {
// Create global hook
const globalHookPath = path.join(globalHooksDir, "TaskComplete")
const globalHookScript = `#!/usr/bin/env node
console.log(JSON.stringify({
cancel: false,
contextModification: "GLOBAL: Task complete",
errorMessage: ""
}))`
await writeHookScript(globalHookPath, globalHookScript)
// Create workspace hook
const workspaceHookPath = path.join(tempDir, ".clinerules", "hooks", "TaskComplete")
const workspaceHookScript = `#!/usr/bin/env node
console.log(JSON.stringify({
cancel: false,
contextModification: "WORKSPACE: Task complete",
errorMessage: ""
}))`
await writeHookScript(workspaceHookPath, workspaceHookScript)
const factory = new HookFactory()
const runner = await factory.create("TaskComplete")
const result = await runner.run({
taskId: "test-task-id",
taskComplete: {
taskMetadata: {
taskId: "test-task-id",
ulid: "test-ulid",
result: "Test task",
command: "",
},
},
})
result.cancel.should.be.false()
result.contextModification?.should.match(/GLOBAL: Task complete/)
result.contextModification?.should.match(/WORKSPACE: Task complete/)
})
it("should handle when global hook has error but workspace succeeds", async () => {
const globalHookPath = path.join(globalHooksDir, "TaskComplete")
const globalHookScript = `#!/usr/bin/env node
console.error("Global hook error");
process.exit(1);`
await writeHookScript(globalHookPath, globalHookScript)
const workspaceHookPath = path.join(tempDir, ".clinerules", "hooks", "TaskComplete")
const workspaceHookScript = `#!/usr/bin/env node
console.log(JSON.stringify({
cancel: false,
contextModification: "Workspace succeeded",
errorMessage: ""
}))`
await writeHookScript(workspaceHookPath, workspaceHookScript)
const factory = new HookFactory()
const runner = await factory.create("TaskComplete")
// Both hooks run in parallel, if one fails the whole thing fails
try {
await runner.run({
taskId: "test-task-id",
taskComplete: {
taskMetadata: {
taskId: "test-task-id",
ulid: "test-ulid",
result: "Test task",
command: "",
},
},
})
throw new Error("Should have thrown")
} catch (error: any) {
error.message.should.match(/TaskComplete.*exited with code 1/)
}
})
})
describe("No Hook Behavior", () => {
it("should succeed when no hook exists", async () => {
const factory = new HookFactory()
const runner = await factory.create("TaskComplete")
const result = await runner.run({
taskId: "test-task-id",
taskComplete: {
taskMetadata: {
taskId: "test-task-id",
ulid: "test-ulid",
result: "Test task",
command: "",
},
},
})
result.cancel.should.be.false()
})
})
describe("Fixture-Based Tests", () => {
it("should validate representative fixtures end-to-end", async () => {
const scenarios: Array<{
fixtureName: string
resultText: string
assert: (result: HookOutput) => void
}> = [
{
fixtureName: "success",
resultText: "Test task",
assert: (result: HookOutput) => {
result.cancel.should.be.false()
result.contextModification?.should.equal("TaskComplete hook executed successfully")
},
},
{
fixtureName: "context-injection",
resultText: "Build a todo app",
assert: (result: HookOutput) => {
result.cancel.should.be.false()
result.contextModification?.should.equal("COMPLETED: Build a todo app")
},
},
]
for (const scenario of scenarios) {
await withFixtureRunner(
"TaskComplete",
`hooks/taskcomplete/${scenario.fixtureName}`,
hookTestEnv,
async (runner) => {
const result = await runner.run({
taskId: "test-task-id",
taskComplete: {
taskMetadata: {
taskId: "test-task-id",
ulid: "test-ulid",
result: scenario.resultText,
command: "",
},
},
})
scenario.assert(result)
},
)
}
})
it("should preserve fixture-based failure behavior", async () => {
await withFixtureRunner("TaskComplete", "hooks/taskcomplete/error", hookTestEnv, async (runner) => {
try {
await runner.run({
taskId: "test-task-id",
taskComplete: {
taskMetadata: {
taskId: "test-task-id",
ulid: "test-ulid",
result: "Test task",
command: "",
},
},
})
throw new Error("Should have thrown")
} catch (error: unknown) {
getErrorMessage(error).should.match(/TaskComplete.*exited with code 1/)
}
})
})
})
})
@@ -1,681 +0,0 @@
import { afterEach, beforeEach, describe, it } from "bun:test"
import "should"
import fs from "fs/promises"
import path from "path"
import sinon from "sinon"
import { HookOutput } from "../../../shared/proto/cline/hooks"
import { HookFactory } from "../hook-factory"
import { createHookTestEnv, HookTestEnv, stubHookDirs, withFixtureRunner, writeHookScriptForPlatform } from "./test-utils"
describe("TaskResume Hook", () => {
let tempDir: string
let sandbox: sinon.SinonSandbox
let hookTestEnv: HookTestEnv
const WINDOWS_HOOK_TEST_TIMEOUT_MS = 15000
type FixtureScenario = {
fixtureName: string
lastMessageTs: string
messageCount: string
conversationHistoryDeleted: string
assert: (result: HookOutput) => void
}
const getErrorMessage = (error: unknown): string => (error instanceof Error ? error.message : String(error))
const writeHookScript = async (hookPath: string, nodeScript: string): Promise<void> => {
await writeHookScriptForPlatform(hookPath, nodeScript)
}
beforeEach(async () => {
hookTestEnv = await createHookTestEnv()
tempDir = hookTestEnv.tempDir
sandbox = hookTestEnv.sandbox
})
afterEach(async () => {
await hookTestEnv.cleanup()
})
describe("Hook Input Format", () => {
it("should receive all required taskResume fields", async () => {
const hookPath = path.join(tempDir, ".clinerules", "hooks", "TaskResume")
const hookScript = `#!/usr/bin/env node
const input = JSON.parse(require('fs').readFileSync(0, 'utf-8'));
const hasRequiredFields =
input.taskResume &&
input.taskResume.taskMetadata &&
input.taskResume.previousState &&
typeof input.taskResume.taskMetadata.taskId === 'string' &&
typeof input.taskResume.previousState.messageCount === 'string';
console.log(JSON.stringify({
cancel: false,
contextModification: hasRequiredFields ? "All fields present" : "Missing fields"
}))`
await writeHookScript(hookPath, hookScript)
const factory = new HookFactory()
const runner = await factory.create("TaskResume")
const result = await runner.run({
taskId: "test-task",
taskResume: {
taskMetadata: {
taskId: "test-task",
ulid: "test-ulid",
},
previousState: {
lastMessageTs: Date.now().toString(),
messageCount: "5",
conversationHistoryDeleted: "false",
},
},
})
result.cancel.should.be.false()
result.contextModification?.should.equal("All fields present")
})
it("should receive all common hook input fields", async () => {
const hookPath = path.join(tempDir, ".clinerules", "hooks", "TaskResume")
const hookScript = `#!/usr/bin/env node
const input = JSON.parse(require('fs').readFileSync(0, 'utf-8'));
const hasAllFields = input.clineVersion && input.hookName && input.timestamp &&
input.taskId && input.workspaceRoots !== undefined &&
input.model && input.model.provider && input.model.slug;
console.log(JSON.stringify({
cancel: false,
contextModification: hasAllFields ? "All fields present" : "Missing fields"
}))`
await writeHookScript(hookPath, hookScript)
const factory = new HookFactory()
const runner = await factory.create("TaskResume")
const result = await runner.run({
taskId: "test-task",
taskResume: {
taskMetadata: { taskId: "test-task", ulid: "test-ulid" },
previousState: {
lastMessageTs: Date.now().toString(),
messageCount: "5",
conversationHistoryDeleted: "false",
},
},
})
result.contextModification?.should.equal("All fields present")
})
})
describe("Time-Based Calculations", () => {
it(
"should correctly calculate minutes ago for recent resumes",
async () => {
if (process.platform === "win32") {
}
const hookPath = path.join(tempDir, ".clinerules", "hooks", "TaskResume")
const hookScript = `#!/usr/bin/env node
const input = JSON.parse(require('fs').readFileSync(0, 'utf-8'));
const lastTs = parseInt(input.taskResume.previousState.lastMessageTs);
const now = Date.now();
const minutesAgo = Math.floor((now - lastTs) / 60000);
console.log(JSON.stringify({
cancel: false,
contextModification: "Minutes ago: " + minutesAgo
}))`
await writeHookScript(hookPath, hookScript)
const factory = new HookFactory()
const runner = await factory.create("TaskResume")
// Test various time intervals
const testCases = [
{ offset: 2 * 60 * 1000, expected: 2 }, // 2 minutes
{ offset: 30 * 60 * 1000, expected: 30 }, // 30 minutes
{ offset: 90 * 60 * 1000, expected: 90 }, // 90 minutes
]
for (const { offset, expected } of testCases) {
const timestamp = Date.now() - offset
const result = await runner.run({
taskId: "test-task",
taskResume: {
taskMetadata: { taskId: "test-task", ulid: "test-ulid" },
previousState: {
lastMessageTs: timestamp.toString(),
messageCount: "5",
conversationHistoryDeleted: "false",
},
},
})
result.contextModification?.should.equal(`Minutes ago: ${expected}`)
}
},
WINDOWS_HOOK_TEST_TIMEOUT_MS,
)
it(
"should handle very old timestamps (days ago)",
async () => {
if (process.platform === "win32") {
}
const hookPath = path.join(tempDir, ".clinerules", "hooks", "TaskResume")
const hookScript = `#!/usr/bin/env node
const input = JSON.parse(require('fs').readFileSync(0, 'utf-8'));
const lastTs = parseInt(input.taskResume.previousState.lastMessageTs);
const now = Date.now();
const daysAgo = Math.floor((now - lastTs) / (24 * 60 * 60 * 1000));
console.log(JSON.stringify({
cancel: false,
contextModification: daysAgo > 0 ? "Days ago: " + daysAgo : "Recent"
}))`
await writeHookScript(hookPath, hookScript)
const factory = new HookFactory()
const runner = await factory.create("TaskResume")
// Test 7 days ago
const sevenDaysAgo = Date.now() - 7 * 24 * 60 * 60 * 1000
const result = await runner.run({
taskId: "test-task",
taskResume: {
taskMetadata: { taskId: "test-task", ulid: "test-ulid" },
previousState: {
lastMessageTs: sevenDaysAgo.toString(),
messageCount: "5",
conversationHistoryDeleted: "false",
},
},
})
result.contextModification?.should.equal("Days ago: 7")
},
WINDOWS_HOOK_TEST_TIMEOUT_MS,
)
it("should handle edge case: future timestamp", async () => {
const hookPath = path.join(tempDir, ".clinerules", "hooks", "TaskResume")
const hookScript = `#!/usr/bin/env node
const input = JSON.parse(require('fs').readFileSync(0, 'utf-8'));
const lastTs = parseInt(input.taskResume.previousState.lastMessageTs);
const now = Date.now();
const isFuture = lastTs > now;
console.log(JSON.stringify({
cancel: false,
contextModification: isFuture ? "Future timestamp detected" : "Normal timestamp"
}))`
await writeHookScript(hookPath, hookScript)
const factory = new HookFactory()
const runner = await factory.create("TaskResume")
const futureTimestamp = Date.now() + 60 * 60 * 1000 // 1 hour in future
const result = await runner.run({
taskId: "test-task",
taskResume: {
taskMetadata: { taskId: "test-task", ulid: "test-ulid" },
previousState: {
lastMessageTs: futureTimestamp.toString(),
messageCount: "5",
conversationHistoryDeleted: "false",
},
},
})
result.contextModification?.should.equal("Future timestamp detected")
})
})
describe("Message Count Analysis", () => {
it(
"should analyze message count thresholds",
async () => {
if (process.platform === "win32") {
}
const hookPath = path.join(tempDir, ".clinerules", "hooks", "TaskResume")
const hookScript = `#!/usr/bin/env node
const input = JSON.parse(require('fs').readFileSync(0, 'utf-8'));
const count = parseInt(input.taskResume.previousState.messageCount);
let category;
if (count < 5) category = "short";
else if (count < 20) category = "medium";
else category = "long";
console.log(JSON.stringify({
cancel: false,
contextModification: "Conversation length: " + category + " (" + count + " messages)"
}))`
await writeHookScript(hookPath, hookScript)
const factory = new HookFactory()
const runner = await factory.create("TaskResume")
const testCases = [
{ count: "2", expected: "short (2 messages)" },
{ count: "10", expected: "medium (10 messages)" },
{ count: "50", expected: "long (50 messages)" },
]
for (const { count, expected } of testCases) {
const result = await runner.run({
taskId: "test-task",
taskResume: {
taskMetadata: { taskId: "test-task", ulid: "test-ulid" },
previousState: {
lastMessageTs: Date.now().toString(),
messageCount: count,
conversationHistoryDeleted: "false",
},
},
})
result.contextModification?.should.equal(`Conversation length: ${expected}`)
}
},
WINDOWS_HOOK_TEST_TIMEOUT_MS,
)
it("should handle zero message count", async () => {
const hookPath = path.join(tempDir, ".clinerules", "hooks", "TaskResume")
const hookScript = `#!/usr/bin/env node
const input = JSON.parse(require('fs').readFileSync(0, 'utf-8'));
const count = parseInt(input.taskResume.previousState.messageCount);
console.log(JSON.stringify({
cancel: false,
contextModification: count === 0 ? "Empty conversation" : "Has messages"
}))`
await writeHookScript(hookPath, hookScript)
const factory = new HookFactory()
const runner = await factory.create("TaskResume")
const result = await runner.run({
taskId: "test-task",
taskResume: {
taskMetadata: { taskId: "test-task", ulid: "test-ulid" },
previousState: {
lastMessageTs: Date.now().toString(),
messageCount: "0",
conversationHistoryDeleted: "false",
},
},
})
result.contextModification?.should.equal("Empty conversation")
})
})
describe("State Combination Analysis", () => {
it("should analyze combination of long pause and many messages", async () => {
const hookPath = path.join(tempDir, ".clinerules", "hooks", "TaskResume")
const hookScript = `#!/usr/bin/env node
const input = JSON.parse(require('fs').readFileSync(0, 'utf-8'));
const lastTs = parseInt(input.taskResume.previousState.lastMessageTs);
const count = parseInt(input.taskResume.previousState.messageCount);
const hoursAgo = Math.floor((Date.now() - lastTs) / (60 * 60 * 1000));
const isStale = hoursAgo > 24 && count > 20;
console.log(JSON.stringify({
cancel: false,
contextModification: isStale ? "STALE_TASK: Long conversation paused for extended time" : "Active task"
}))`
await writeHookScript(hookPath, hookScript)
const factory = new HookFactory()
const runner = await factory.create("TaskResume")
const oneDayAgo = Date.now() - 25 * 60 * 60 * 1000
const result = await runner.run({
taskId: "test-task",
taskResume: {
taskMetadata: { taskId: "test-task", ulid: "test-ulid" },
previousState: {
lastMessageTs: oneDayAgo.toString(),
messageCount: "30",
conversationHistoryDeleted: "false",
},
},
})
result.contextModification?.should.equal("STALE_TASK: Long conversation paused for extended time")
})
it("should combine context deletion with other state", async () => {
const hookPath = path.join(tempDir, ".clinerules", "hooks", "TaskResume")
const hookScript = `#!/usr/bin/env node
const input = JSON.parse(require('fs').readFileSync(0, 'utf-8'));
const deleted = input.taskResume.previousState.conversationHistoryDeleted === 'true';
const count = parseInt(input.taskResume.previousState.messageCount);
console.log(JSON.stringify({
cancel: false,
contextModification: deleted && count > 10
? "CONTEXT_WARNING: Large conversation with truncated history"
: "Normal state"
}))`
await writeHookScript(hookPath, hookScript)
const factory = new HookFactory()
const runner = await factory.create("TaskResume")
const result = await runner.run({
taskId: "test-task",
taskResume: {
taskMetadata: { taskId: "test-task", ulid: "test-ulid" },
previousState: {
lastMessageTs: Date.now().toString(),
messageCount: "25",
conversationHistoryDeleted: "true",
},
},
})
result.contextModification?.should.equal("CONTEXT_WARNING: Large conversation with truncated history")
})
})
describe("Error Handling", () => {
it("should handle malformed JSON output", async () => {
const hookPath = path.join(tempDir, ".clinerules", "hooks", "TaskResume")
const hookScript = `#!/usr/bin/env node
console.log("not valid json")`
await writeHookScript(hookPath, hookScript)
const factory = new HookFactory()
const runner = await factory.create("TaskResume")
// When hook exits 0 but has malformed JSON, it returns success without context
const result = await runner.run({
taskId: "test-task",
taskResume: {
taskMetadata: { taskId: "test-task", ulid: "test-ulid" },
previousState: {
lastMessageTs: Date.now().toString(),
messageCount: "5",
conversationHistoryDeleted: "false",
},
},
})
// Hook succeeded (exit 0) but couldn't parse JSON, so returns success without context
result.cancel.should.be.false()
;(result.contextModification === undefined || result.contextModification === "").should.be.true()
})
it("should handle invalid timestamp gracefully", async () => {
const hookPath = path.join(tempDir, ".clinerules", "hooks", "TaskResume")
const hookScript = `#!/usr/bin/env node
const input = JSON.parse(require('fs').readFileSync(0, 'utf-8'));
const lastTs = parseInt(input.taskResume.previousState.lastMessageTs);
const isValid = !isNaN(lastTs) && lastTs > 0;
console.log(JSON.stringify({
cancel: false,
contextModification: isValid ? "Valid timestamp" : "Invalid timestamp"
}))`
await writeHookScript(hookPath, hookScript)
const factory = new HookFactory()
const runner = await factory.create("TaskResume")
const result = await runner.run({
taskId: "test-task",
taskResume: {
taskMetadata: { taskId: "test-task", ulid: "test-ulid" },
previousState: {
lastMessageTs: "invalid",
messageCount: "5",
conversationHistoryDeleted: "false",
},
},
})
result.contextModification?.should.equal("Invalid timestamp")
})
})
describe("Global and Workspace Hooks", () => {
let globalHooksDir: string
let workspaceHooksDir: string
beforeEach(async () => {
globalHooksDir = path.join(tempDir, "global-hooks")
await fs.mkdir(globalHooksDir, { recursive: true })
workspaceHooksDir = path.join(tempDir, ".clinerules", "hooks")
stubHookDirs(sandbox, [globalHooksDir, workspaceHooksDir])
})
it("should execute both global and workspace TaskResume hooks", async () => {
const globalHookPath = path.join(globalHooksDir, "TaskResume")
const globalHookScript = `#!/usr/bin/env node
console.log(JSON.stringify({
cancel: false,
contextModification: "GLOBAL: Task resumed"
}))`
await writeHookScript(globalHookPath, globalHookScript)
const workspaceHookPath = path.join(tempDir, ".clinerules", "hooks", "TaskResume")
const workspaceHookScript = `#!/usr/bin/env node
console.log(JSON.stringify({
cancel: false,
contextModification: "WORKSPACE: Task resumed"
}))`
await writeHookScript(workspaceHookPath, workspaceHookScript)
const factory = new HookFactory()
const runner = await factory.create("TaskResume")
const result = await runner.run({
taskId: "test-task",
taskResume: {
taskMetadata: { taskId: "test-task", ulid: "test-ulid" },
previousState: {
lastMessageTs: Date.now().toString(),
messageCount: "5",
conversationHistoryDeleted: "false",
},
},
})
result.cancel.should.be.false()
result.contextModification?.should.match(/GLOBAL: Task resumed/)
result.contextModification?.should.match(/WORKSPACE: Task resumed/)
})
it("should combine context modifications from both hooks with time analysis", async () => {
const oneDayAgo = Date.now() - 24 * 60 * 60 * 1000
const globalHookPath = path.join(globalHooksDir, "TaskResume")
const globalHookScript = `#!/usr/bin/env node
const input = JSON.parse(require('fs').readFileSync(0, 'utf-8'));
const lastTs = parseInt(input.taskResume.previousState.lastMessageTs);
const daysAgo = Math.floor((Date.now() - lastTs) / (24 * 60 * 60 * 1000));
console.log(JSON.stringify({
cancel: false,
contextModification: "GLOBAL_POLICY: " + (daysAgo > 0 ? "Review task context" : "Continue")
}))`
await writeHookScript(globalHookPath, globalHookScript)
const workspaceHookPath = path.join(tempDir, ".clinerules", "hooks", "TaskResume")
const workspaceHookScript = `#!/usr/bin/env node
const input = JSON.parse(require('fs').readFileSync(0, 'utf-8'));
const count = parseInt(input.taskResume.previousState.messageCount);
console.log(JSON.stringify({
cancel: false,
contextModification: "PROJECT_NOTE: " + count + " messages in history"
}))`
await writeHookScript(workspaceHookPath, workspaceHookScript)
const factory = new HookFactory()
const runner = await factory.create("TaskResume")
const result = await runner.run({
taskId: "test-task",
taskResume: {
taskMetadata: { taskId: "test-task", ulid: "test-ulid" },
previousState: {
lastMessageTs: oneDayAgo.toString(),
messageCount: "15",
conversationHistoryDeleted: "false",
},
},
})
result.contextModification?.should.match(/GLOBAL_POLICY: Review task context/)
result.contextModification?.should.match(/PROJECT_NOTE: 15 messages in history/)
})
})
describe("No Hook Behavior", () => {
it("should allow resume when no hook exists", async () => {
const factory = new HookFactory()
const runner = await factory.create("TaskResume")
const result = await runner.run({
taskId: "test-task",
taskResume: {
taskMetadata: { taskId: "test-task", ulid: "test-ulid" },
previousState: {
lastMessageTs: Date.now().toString(),
messageCount: "5",
conversationHistoryDeleted: "false",
},
},
})
result.cancel.should.be.false()
})
})
describe("Fixture-Based Tests", () => {
it(
"should validate representative fixtures end-to-end",
async () => {
// Multiple fixture scenarios spawn child processes sequentially,
// which can easily exceed the default 2 s Mocha timeout.
const scenarios: FixtureScenario[] = [
{
fixtureName: "success",
lastMessageTs: Date.now().toString(),
messageCount: "5",
conversationHistoryDeleted: "false",
assert: (result: HookOutput) => {
result.cancel.should.be.false()
result.contextModification?.should.equal("TaskResume hook executed successfully")
},
},
{
fixtureName: "recent-resume",
lastMessageTs: (Date.now() - 2 * 60 * 1000).toString(),
messageCount: "5",
conversationHistoryDeleted: "false",
assert: (result: HookOutput) => {
result.cancel.should.be.false()
result.contextModification?.should.match(/Recently paused task/)
},
},
{
fixtureName: "long-pause",
lastMessageTs: (Date.now() - 48 * 60 * 60 * 1000).toString(),
messageCount: "5",
conversationHistoryDeleted: "false",
assert: (result: HookOutput) => {
result.cancel.should.be.false()
result.contextModification?.should.match(/paused 48 hours ago/)
},
},
{
fixtureName: "context-deleted",
lastMessageTs: Date.now().toString(),
messageCount: "50",
conversationHistoryDeleted: "true",
assert: (result: HookOutput) => {
result.cancel.should.be.false()
result.contextModification?.should.match(/truncated/)
},
},
{
fixtureName: "message-count",
lastMessageTs: Date.now().toString(),
messageCount: "25",
conversationHistoryDeleted: "false",
assert: (result: HookOutput) => {
result.cancel.should.be.false()
result.contextModification?.should.equal("TASK_CONTEXT: Resuming task with 25 previous messages")
},
},
{
fixtureName: "context-injection",
lastMessageTs: Date.now().toString(),
messageCount: "5",
conversationHistoryDeleted: "false",
assert: (result: HookOutput) => {
result.cancel.should.be.false()
result.contextModification?.should.equal(
"WORKSPACE_RULES: Task test-task resumed - review previous context",
)
},
},
]
for (const scenario of scenarios) {
await withFixtureRunner(
"TaskResume",
`hooks/taskresume/${scenario.fixtureName}`,
hookTestEnv,
async (runner) => {
const result = await runner.run({
taskId: "test-task",
taskResume: {
taskMetadata: { taskId: "test-task", ulid: "test-ulid" },
previousState: {
lastMessageTs: scenario.lastMessageTs,
messageCount: scenario.messageCount,
conversationHistoryDeleted: scenario.conversationHistoryDeleted,
},
},
})
scenario.assert(result)
},
)
}
},
WINDOWS_HOOK_TEST_TIMEOUT_MS,
)
it("should preserve fixture-based failure behavior", async () => {
await withFixtureRunner("TaskResume", "hooks/taskresume/error", hookTestEnv, async (runner) => {
try {
await runner.run({
taskId: "test-task",
taskResume: {
taskMetadata: { taskId: "test-task", ulid: "test-ulid" },
previousState: {
lastMessageTs: Date.now().toString(),
messageCount: "5",
conversationHistoryDeleted: "false",
},
},
})
throw new Error("Should have thrown")
} catch (error: unknown) {
getErrorMessage(error).should.match(/exited with code 1/)
}
})
})
})
})
@@ -1,482 +0,0 @@
import { afterEach, beforeEach, describe, it } from "bun:test"
import "should"
import fs from "fs/promises"
import path from "path"
import sinon from "sinon"
import { HookOutput } from "../../../shared/proto/cline/hooks"
import { HookFactory } from "../hook-factory"
import { createHookTestEnv, HookTestEnv, stubHookDirs, withFixtureRunner, writeHookScriptForPlatform } from "./test-utils"
describe("TaskStart Hook", () => {
let tempDir: string
let sandbox: sinon.SinonSandbox
let getEnv: () => { tempDir: string }
let hookTestEnv: HookTestEnv
const getErrorMessage = (error: unknown): string => (error instanceof Error ? error.message : String(error))
const writeHookScript = async (hookPath: string, nodeScript: string): Promise<void> => {
await writeHookScriptForPlatform(hookPath, nodeScript)
}
beforeEach(async () => {
hookTestEnv = await createHookTestEnv()
tempDir = hookTestEnv.tempDir
sandbox = hookTestEnv.sandbox
getEnv = () => ({ tempDir })
})
afterEach(async () => {
await hookTestEnv.cleanup()
})
describe("Hook Input Format", () => {
it("should receive task metadata from startTask", async () => {
const hookPath = path.join(tempDir, ".clinerules", "hooks", "TaskStart")
const hookScript = `#!/usr/bin/env node
const input = JSON.parse(require('fs').readFileSync(0, 'utf-8'));
const metadata = input.taskStart.taskMetadata;
const hasAllFields = metadata.taskId && metadata.ulid && 'initialTask' in metadata;
console.log(JSON.stringify({
cancel: false,
contextModification: hasAllFields ? "All metadata present" : "Missing metadata",
errorMessage: ""
}))`
await writeHookScript(hookPath, hookScript)
const factory = new HookFactory()
const runner = await factory.create("TaskStart")
const result = await runner.run({
taskId: "test-task-id",
taskStart: {
taskMetadata: {
taskId: "test-task-id",
ulid: "test-ulid",
initialTask: "Build a todo app",
},
},
})
result.cancel.should.be.false()
result.contextModification?.should.equal("All metadata present")
})
it("should receive all common hook input fields", async () => {
const hookPath = path.join(tempDir, ".clinerules", "hooks", "TaskStart")
const hookScript = `#!/usr/bin/env node
const input = JSON.parse(require('fs').readFileSync(0, 'utf-8'));
const hasAllFields = input.clineVersion && input.hookName === 'TaskStart' &&
input.timestamp && input.taskId &&
input.workspaceRoots !== undefined &&
input.model && input.model.provider && input.model.slug;
console.log(JSON.stringify({
cancel: false,
contextModification: hasAllFields ? "All fields present" : "Missing fields",
errorMessage: ""
}))`
await writeHookScript(hookPath, hookScript)
const factory = new HookFactory()
const runner = await factory.create("TaskStart")
const result = await runner.run({
taskId: "test-task-id",
taskStart: {
taskMetadata: {
taskId: "test-task-id",
ulid: "test-ulid",
initialTask: "Test task",
},
},
})
result.cancel.should.be.false()
result.contextModification?.should.equal("All fields present")
})
it("should handle empty initialTask", async () => {
const hookPath = path.join(tempDir, ".clinerules", "hooks", "TaskStart")
const hookScript = `#!/usr/bin/env node
const input = JSON.parse(require('fs').readFileSync(0, 'utf-8'));
const initialTask = input.taskStart.taskMetadata.initialTask;
console.log(JSON.stringify({
cancel: false,
contextModification: "Task length: " + initialTask.length,
errorMessage: ""
}))`
await writeHookScript(hookPath, hookScript)
const factory = new HookFactory()
const runner = await factory.create("TaskStart")
const result = await runner.run({
taskId: "test-task-id",
taskStart: {
taskMetadata: {
taskId: "test-task-id",
ulid: "test-ulid",
initialTask: "",
},
},
})
result.cancel.should.be.false()
result.contextModification?.should.equal("Task length: 0")
})
})
describe("Hook Behavior", () => {
it("should allow task to start when hook returns cancel: false", async () => {
const hookPath = path.join(tempDir, ".clinerules", "hooks", "TaskStart")
const hookScript = `#!/usr/bin/env node
console.log(JSON.stringify({
cancel: false,
contextModification: "TaskStart hook executed successfully",
errorMessage: ""
}))`
await writeHookScript(hookPath, hookScript)
const factory = new HookFactory()
const runner = await factory.create("TaskStart")
const result = await runner.run({
taskId: "test-task-id",
taskStart: {
taskMetadata: {
taskId: "test-task-id",
ulid: "test-ulid",
initialTask: "Test task",
},
},
})
result.cancel.should.be.false()
result.contextModification?.should.equal("TaskStart hook executed successfully")
})
it("should block task when hook returns cancel: true", async () => {
const hookPath = path.join(tempDir, ".clinerules", "hooks", "TaskStart")
const hookScript = `#!/usr/bin/env node
console.log(JSON.stringify({
cancel: true,
contextModification: "",
errorMessage: "Task execution blocked by hook"
}))`
await writeHookScript(hookPath, hookScript)
const factory = new HookFactory()
const runner = await factory.create("TaskStart")
const result = await runner.run({
taskId: "test-task-id",
taskStart: {
taskMetadata: {
taskId: "test-task-id",
ulid: "test-ulid",
initialTask: "Test task",
},
},
})
result.cancel.should.be.true()
result.errorMessage?.should.equal("Task execution blocked by hook")
})
it("should provide context modification even when not added to conversation", async () => {
const hookPath = path.join(tempDir, ".clinerules", "hooks", "TaskStart")
const hookScript = `#!/usr/bin/env node
const input = JSON.parse(require('fs').readFileSync(0, 'utf-8'));
console.log(JSON.stringify({
cancel: false,
contextModification: "TASK_START: Task '" + input.taskStart.taskMetadata.initialTask + "' beginning",
errorMessage: ""
}))`
await writeHookScript(hookPath, hookScript)
const factory = new HookFactory()
const runner = await factory.create("TaskStart")
const result = await runner.run({
taskId: "test-task-id",
taskStart: {
taskMetadata: {
taskId: "test-task-id",
ulid: "test-ulid",
initialTask: "Build a todo app",
},
},
})
result.cancel.should.be.false()
result.contextModification?.should.equal("TASK_START: Task 'Build a todo app' beginning")
})
})
describe("Error Handling", () => {
it("should handle hook script errors", async () => {
const hookPath = path.join(tempDir, ".clinerules", "hooks", "TaskStart")
const hookScript = `#!/usr/bin/env node
console.error("Hook execution error");
process.exit(1);`
await writeHookScript(hookPath, hookScript)
const factory = new HookFactory()
const runner = await factory.create("TaskStart")
try {
await runner.run({
taskId: "test-task-id",
taskStart: {
taskMetadata: {
taskId: "test-task-id",
ulid: "test-ulid",
initialTask: "Test task",
},
},
})
throw new Error("Should have thrown")
} catch (error: any) {
error.message.should.match(/TaskStart.*exited with code 1/)
}
})
it("should handle malformed JSON output from hook", async () => {
const hookPath = path.join(tempDir, ".clinerules", "hooks", "TaskStart")
const hookScript = `#!/usr/bin/env node
console.log("not valid json")`
await writeHookScript(hookPath, hookScript)
const factory = new HookFactory()
const runner = await factory.create("TaskStart")
// When hook exits 0 but has malformed JSON, it returns success without context
const result = await runner.run({
taskId: "test-task-id",
taskStart: {
taskMetadata: {
taskId: "test-task-id",
ulid: "test-ulid",
initialTask: "Test task",
},
},
})
// Hook succeeded (exit 0) but couldn't parse JSON, so returns success without context
result.cancel.should.be.false()
;(result.contextModification === undefined || result.contextModification === "").should.be.true()
})
})
describe("Global and Workspace Hooks", () => {
let globalHooksDir: string
let workspaceHooksDir: string
beforeEach(async () => {
// Create global hooks directory
globalHooksDir = path.join(tempDir, "global-hooks")
await fs.mkdir(globalHooksDir, { recursive: true })
workspaceHooksDir = path.join(tempDir, ".clinerules", "hooks")
// Use deterministic hook directories to avoid test flakiness.
stubHookDirs(sandbox, [globalHooksDir, workspaceHooksDir])
})
it("should execute both global and workspace TaskStart hooks", async () => {
// Create global hook
const globalHookPath = path.join(globalHooksDir, "TaskStart")
const globalHookScript = `#!/usr/bin/env node
console.log(JSON.stringify({
cancel: false,
contextModification: "GLOBAL: Task starting",
errorMessage: ""
}))`
await writeHookScript(globalHookPath, globalHookScript)
// Create workspace hook
const workspaceHookPath = path.join(tempDir, ".clinerules", "hooks", "TaskStart")
const workspaceHookScript = `#!/usr/bin/env node
console.log(JSON.stringify({
cancel: false,
contextModification: "WORKSPACE: Task starting",
errorMessage: ""
}))`
await writeHookScript(workspaceHookPath, workspaceHookScript)
const factory = new HookFactory()
const runner = await factory.create("TaskStart")
const result = await runner.run({
taskId: "test-task-id",
taskStart: {
taskMetadata: {
taskId: "test-task-id",
ulid: "test-ulid",
initialTask: "Test task",
},
},
})
result.cancel.should.be.false()
result.contextModification?.should.match(/GLOBAL: Task starting/)
result.contextModification?.should.match(/WORKSPACE: Task starting/)
})
it("should block if global hook blocks", async () => {
const globalHookPath = path.join(globalHooksDir, "TaskStart")
const globalHookScript = `#!/usr/bin/env node
console.log(JSON.stringify({
cancel: true,
contextModification: "",
errorMessage: "Global policy blocks this task"
}))`
await writeHookScript(globalHookPath, globalHookScript)
const workspaceHookPath = path.join(tempDir, ".clinerules", "hooks", "TaskStart")
const workspaceHookScript = `#!/usr/bin/env node
console.log(JSON.stringify({
cancel: false,
contextModification: "Workspace allows",
errorMessage: ""
}))`
await writeHookScript(workspaceHookPath, workspaceHookScript)
const factory = new HookFactory()
const runner = await factory.create("TaskStart")
const result = await runner.run({
taskId: "test-task-id",
taskStart: {
taskMetadata: {
taskId: "test-task-id",
ulid: "test-ulid",
initialTask: "Test task",
},
},
})
result.cancel.should.be.true()
result.errorMessage?.should.match(/Global policy blocks this task/)
})
it("should block if workspace hook blocks even when global allows", async () => {
const globalHookPath = path.join(globalHooksDir, "TaskStart")
const globalHookScript = `#!/usr/bin/env node
console.log(JSON.stringify({
cancel: false,
contextModification: "Global allows",
errorMessage: ""
}))`
await writeHookScript(globalHookPath, globalHookScript)
const workspaceHookPath = path.join(tempDir, ".clinerules", "hooks", "TaskStart")
const workspaceHookScript = `#!/usr/bin/env node
console.log(JSON.stringify({
cancel: true,
contextModification: "",
errorMessage: "Workspace blocks"
}))`
await writeHookScript(workspaceHookPath, workspaceHookScript)
const factory = new HookFactory()
const runner = await factory.create("TaskStart")
const result = await runner.run({
taskId: "test-task-id",
taskStart: {
taskMetadata: {
taskId: "test-task-id",
ulid: "test-ulid",
initialTask: "Test task",
},
},
})
result.cancel.should.be.true()
result.errorMessage?.should.match(/Workspace blocks/)
})
})
describe("No Hook Behavior", () => {
it("should allow task when no hook exists", async () => {
const factory = new HookFactory()
const runner = await factory.create("TaskStart")
const result = await runner.run({
taskId: "test-task-id",
taskStart: {
taskMetadata: {
taskId: "test-task-id",
ulid: "test-ulid",
initialTask: "Test task",
},
},
})
result.cancel.should.be.false()
})
})
describe("Fixture-Based Tests", () => {
it("should validate representative fixtures end-to-end", async () => {
const scenarios: Array<{ fixtureName: string; assert: (result: HookOutput) => void }> = [
{
fixtureName: "success",
assert: (result: HookOutput) => {
result.cancel.should.be.false()
result.contextModification?.should.equal("TaskStart hook executed successfully")
},
},
{
fixtureName: "blocking",
assert: (result: HookOutput) => {
result.cancel.should.be.true()
result.errorMessage?.should.equal("Task execution blocked by hook")
},
},
]
for (const scenario of scenarios) {
await withFixtureRunner("TaskStart", `hooks/taskstart/${scenario.fixtureName}`, hookTestEnv, async (runner) => {
const result = await runner.run({
taskId: "test-task-id",
taskStart: {
taskMetadata: {
taskId: "test-task-id",
ulid: "test-ulid",
initialTask: "Test task",
},
},
})
scenario.assert(result)
})
}
})
it("should preserve fixture-based failure behavior", async () => {
await withFixtureRunner("TaskStart", "hooks/taskstart/error", hookTestEnv, async (runner) => {
try {
await runner.run({
taskId: "test-task-id",
taskStart: {
taskMetadata: {
taskId: "test-task-id",
ulid: "test-ulid",
initialTask: "Test task",
},
},
})
throw new Error("Should have thrown")
} catch (error: unknown) {
getErrorMessage(error).should.match(/TaskStart.*exited with code 1/)
}
})
})
})
})
@@ -1,647 +0,0 @@
import { spyOn } from "bun:test"
import * as fs from "fs/promises"
import * as os from "os"
import * as path from "path"
import should from "should"
import sinon from "sinon"
import { HostProvider } from "../../../hosts/host-provider"
import { HookOutput } from "../../../shared/proto/cline/hooks"
import { setVscodeHostProviderMock } from "../../../test/host-provider-test-utils"
import * as diskModule from "../../storage/disk"
import { StateManager } from "../../storage/StateManager"
import { HookDiscoveryCache } from "../HookDiscoveryCache"
import { HookFactory, Hooks, NamedHookInput } from "../hook-factory"
// Define HookName locally since it's not exported from hook-factory
type HookName = keyof Hooks
export type HookTestEnv = {
tempDir: string
hooksDir: string
sandbox: sinon.SinonSandbox
cleanup: () => Promise<void>
}
async function removeTempDirWithRetry(tempDir: string): Promise<void> {
const maxAttempts = process.platform === "win32" ? 5 : 1
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
try {
await fs.rm(tempDir, { recursive: true, force: true })
return
} catch (error) {
const nodeError = error as NodeJS.ErrnoException
const isRetryableWindowsLock = process.platform === "win32" && nodeError?.code === "EBUSY"
if (!isRetryableWindowsLock || attempt === maxAttempts) {
throw error
}
// Give Windows a brief moment to release file handles from child processes.
await new Promise((resolve) => setTimeout(resolve, 100 * attempt))
}
}
}
export function resetHookCache(): void {
HookDiscoveryCache.resetForTesting()
}
export async function withPlatform<T>(platform: NodeJS.Platform, fn: () => Promise<T> | T): Promise<T> {
const originalPlatform = process.platform
Object.defineProperty(process, "platform", { value: platform, configurable: true })
try {
return await fn()
} finally {
Object.defineProperty(process, "platform", { value: originalPlatform, configurable: true })
}
}
export function hookFileName(hookName: string, platform: NodeJS.Platform = process.platform): string {
return platform === "win32" ? `${hookName}.ps1` : hookName
}
export function hookPath(hooksDir: string, hookName: string, platform: NodeJS.Platform = process.platform): string {
return path.join(hooksDir, hookFileName(hookName, platform))
}
// bun loads real ESM, so sinon cannot stub the `getAllHooksDirs` namespace
// export ("ES Modules cannot be stubbed"). Use bun's spyOn, which can replace
// ESM namespace bindings in place. The spy is tracked module-locally so the
// per-env cleanup can restore it (the sandbox arg is retained for call-site
// compatibility but is unused for this export).
let hooksDirsSpy: ReturnType<typeof spyOn> | undefined
export function stubHookDirs(_sandbox: sinon.SinonSandbox, dirs: string[]): ReturnType<typeof spyOn> {
if (!hooksDirsSpy) {
hooksDirsSpy = spyOn(diskModule, "getAllHooksDirs")
}
hooksDirsSpy.mockImplementation(async () => dirs)
return hooksDirsSpy
}
function restoreHookDirsSpy(): void {
hooksDirsSpy?.mockRestore()
hooksDirsSpy = undefined
}
export async function createHookTestEnv(): Promise<HookTestEnv> {
// Hook execution emits telemetry, which lazily constructs TelemetryService
// via HostProvider.env.getHostVersion(). Under mocha's single-process run an
// earlier suite left HostProvider initialized; bun's per-file isolation does
// not, so initialize it here (idempotent) to keep the telemetry path from
// throwing "HostProvider not setup".
if (!HostProvider.isInitialized()) {
setVscodeHostProviderMock()
}
const sandbox = sinon.createSandbox()
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "hook-test-"))
const hooksDir = await createHooksDirectory(tempDir)
sandbox.stub(StateManager, "get").returns({
getGlobalStateKey: (key: string) => {
if (key === "workspaceRoots") {
return [{ path: tempDir }]
}
if (key === "primaryRootIndex") {
return 0
}
return undefined
},
} as any)
resetHookCache()
stubHookDirs(sandbox, [hooksDir])
return {
tempDir,
hooksDir,
sandbox,
cleanup: async () => {
sandbox.restore()
restoreHookDirsSpy()
resetHookCache()
await removeTempDirWithRetry(tempDir)
},
}
}
/**
* Creates a hooks directory structure at the specified location.
*
* @param baseDir Base directory where .clinerules/hooks will be created
* @returns Path to the created hooks directory
*
* @example
* const hooksDir = await createHooksDirectory("/tmp/test")
* // Returns: "/tmp/test/.clinerules/hooks"
*/
export async function createHooksDirectory(baseDir: string): Promise<string> {
const hooksDir = path.join(baseDir, ".clinerules", "hooks")
await fs.mkdir(hooksDir, { recursive: true })
return hooksDir
}
/**
* Creates a test hook script with the specified output behavior.
*
* On Unix, this writes an executable `HookName` script with a shebang.
* On Windows, this writes both:
* - `HookName` (PowerShell bridge script)
* - `HookName.js` (Node implementation)
*
* This mirrors runtime behavior where Windows hooks execute via PowerShell.
*
* @param baseDir Base directory (typically tempDir from test environment)
* @param hookName Name of the hook (e.g., "PreToolUse", "PostToolUse")
* @param output The JSON output the hook should return
* @param options Optional configuration for hook behavior
* @returns Path to the created hook script
*
* @example
* // Create a simple success hook
* await createTestHook(tempDir, "PreToolUse", {
* cancel: false,
* contextModification: "TEST_CONTEXT"
* })
*
* @example
* // Create a hook that delays before responding
* await createTestHook(tempDir, "PreToolUse", {
* cancel: false
* }, { delay: 100 })
*
* @example
* // Create a hook that exits with an error
* await createTestHook(tempDir, "PreToolUse", {
* cancel: true
* }, { exitCode: 1 })
*
* @example
* // Create a hook with custom Node.js code
* await createTestHook(tempDir, "PreToolUse", {}, {
* customNodeCode: "console.log('custom behavior'); process.exit(0);"
* })
*/
export async function createTestHook(
baseDir: string,
hookName: string,
output: Partial<HookOutput>,
options: {
delay?: number
exitCode?: number
malformedJson?: boolean
customNodeCode?: string
exitWithoutOutput?: boolean
} = {},
): Promise<string> {
const hooksDir = await createHooksDirectory(baseDir)
const scriptContent = generateHookScript(output, options)
// Create hook scripts compatible with the active platform/runtime.
return writeShellHook(hooksDir, hookName, scriptContent)
}
/**
* Writes a hook script at a specific hook base path (without extension).
*
* - Unix/macOS: writes executable extensionless script directly
* - Windows: writes `<HookName>.ps1` + `<HookName>.js` companion script
*/
export async function writeHookScriptForPlatform(hookPath: string, nodeScript: string): Promise<void> {
if (process.platform === "win32") {
const jsPath = `${hookPath}.js`
const ps1Path = `${hookPath}.ps1`
const psBridge = buildPowerShellNodeBridge(process.execPath, path.basename(jsPath))
await fs.writeFile(jsPath, nodeScript)
await fs.writeFile(ps1Path, psBridge)
return
}
await fs.writeFile(hookPath, nodeScript)
await fs.chmod(hookPath, 0o755)
}
function buildPowerShellNodeBridge(nodePath: string, jsFileName: string): string {
const escapedNodePath = nodePath.replace(/'/g, "''")
const escapedJsFileName = jsFileName.replace(/'/g, "''")
return [
`$ErrorActionPreference = 'Stop'`,
`$scriptPath = Join-Path -Path $PSScriptRoot -ChildPath '${escapedJsFileName}'`,
`$inputData = [Console]::In.ReadToEnd()`,
`$inputData | & '${escapedNodePath}' $scriptPath`,
`if ($null -ne $LASTEXITCODE) { exit $LASTEXITCODE }`,
`exit 0`,
].join("\n")
}
/**
* Generates an executable Node.js script with shebang.
*/
function generateHookScript(
output: Partial<HookOutput>,
options: {
delay?: number
exitCode?: number
malformedJson?: boolean
customNodeCode?: string
exitWithoutOutput?: boolean
},
): string {
let script = "#!/usr/bin/env node\n"
// If custom Node.js code is provided, use it directly
if (options.customNodeCode) {
return script + options.customNodeCode
}
// If exitWithoutOutput is true, just exit
if (options.exitWithoutOutput) {
return `${script}process.exit(0);\n`
}
if (options.delay) {
script += `setTimeout(() => {\n`
}
if (options.malformedJson) {
script += ` console.log("not valid json");\n`
} else {
script += ` console.log(JSON.stringify(${JSON.stringify(output)}));\n`
}
if (options.exitCode !== undefined) {
script += ` process.exit(${options.exitCode});\n`
}
if (options.delay) {
script += `}, ${options.delay});\n`
}
return script
}
/**
* Writes an executable hook script.
*
* Unix: writes executable script directly.
* Windows: writes a PowerShell bridge that pipes stdin to a Node companion script.
*/
async function writeShellHook(hooksDir: string, hookName: string, scriptContent: string): Promise<string> {
const scriptPath = path.join(hooksDir, hookName)
await writeHookScriptForPlatform(scriptPath, scriptContent)
return process.platform === "win32" ? `${scriptPath}.ps1` : scriptPath
}
/**
* Builds a complete HookInput object for PreToolUse testing.
*
* @param params Partial parameters to customize the input
* @returns Complete HookInput ready for runner.run()
*
* @example
* const input = buildPreToolUseInput({
* toolName: "write_to_file",
* parameters: { path: "test.ts", content: "test" }
* })
*/
export function buildPreToolUseInput(params: {
toolName: string
parameters?: Record<string, any>
taskId?: string
}): NamedHookInput<"PreToolUse"> {
return {
taskId: params.taskId || "test-task-id",
preToolUse: {
toolName: params.toolName,
parameters: params.parameters || {},
},
}
}
/**
* Builds a complete HookInput object for PostToolUse testing.
*
* @param params Partial parameters to customize the input
* @returns Complete HookInput ready for runner.run()
*
* @example
* const input = buildPostToolUseInput({
* toolName: "write_to_file",
* result: "File created successfully",
* success: true
* })
*/
export function buildPostToolUseInput(params: {
toolName: string
parameters?: Record<string, any>
result?: string
success?: boolean
executionTimeMs?: number
taskId?: string
}): NamedHookInput<"PostToolUse"> {
return {
taskId: params.taskId || "test-task-id",
postToolUse: {
toolName: params.toolName,
parameters: params.parameters || {},
result: params.result || "",
success: params.success ?? true,
executionTimeMs: params.executionTimeMs ?? 100,
},
}
}
/**
* Assertion helper for HookOutput validation.
* Compares actual output against expected partial output.
*
* @param actual The actual hook output received
* @param expected The expected hook output (partial match)
*
* @example
* assertHookOutput(result, {
* cancel: false,
* contextModification: "Expected context"
* })
*/
export function assertHookOutput(actual: HookOutput, expected: Partial<HookOutput>): void {
if (expected.cancel !== undefined) {
if (actual.cancel !== expected.cancel) {
throw new Error(
`Hook output assertion failed for 'cancel':\n` +
` Expected: ${expected.cancel}\n` +
` Received: ${actual.cancel}\n` +
` Full output: ${JSON.stringify(actual, null, 2)}`,
)
}
}
if (expected.contextModification !== undefined) {
if (actual.contextModification !== expected.contextModification) {
throw new Error(
`Hook output assertion failed for 'contextModification':\n` +
` Expected: "${expected.contextModification}"\n` +
` Received: "${actual.contextModification}"\n` +
` Full output: ${JSON.stringify(actual, null, 2)}`,
)
}
}
if (expected.errorMessage !== undefined) {
if (actual.errorMessage !== expected.errorMessage) {
throw new Error(
`Hook output assertion failed for 'errorMessage':\n` +
` Expected: "${expected.errorMessage}"\n` +
` Received: "${actual.errorMessage}"\n` +
` Full output: ${JSON.stringify(actual, null, 2)}`,
)
}
}
}
/**
* Type guard to check if a value is serializable (can be cloned).
* Prevents errors from attempting to clone non-serializable objects.
*/
function isSerializable(value: any): boolean {
if (value === null || value === undefined) {
return true
}
const type = typeof value
if (type === "string" || type === "number" || type === "boolean") {
return true
}
if (type === "object") {
// Check for non-serializable types
if (value instanceof Function || value instanceof RegExp || value instanceof Error) {
return false
}
// Check if it's an array or plain object
if (Array.isArray(value)) {
return value.every(isSerializable)
}
// For objects, check all values
return Object.values(value).every(isSerializable)
}
return false
}
/**
* Mock implementation of HookRunner for fast integration tests.
* Tracks calls and returns predefined responses without spawning processes.
*
* @example
* const mockRunner = new MockHookRunner("PreToolUse")
* mockRunner.setResponse({ cancel: false })
*
* const result = await mockRunner.run(input)
* mockRunner.assertCalled(1)
* mockRunner.assertCalledWith({ preToolUse: { toolName: "write_to_file" } })
*/
export class MockHookRunner<Name extends HookName> {
private response: HookOutput = {
cancel: false,
contextModification: "",
errorMessage: "",
}
public executionLog: Array<{ input: NamedHookInput<Name>; timestamp: number }> = []
public readonly hookName: Name
constructor(hookName: Name) {
this.hookName = hookName
}
/**
* Set the response this mock should return.
*
* @param output The HookOutput to return on execution
*/
setResponse(output: Partial<HookOutput>): void {
this.response = {
cancel: output.cancel ?? false,
contextModification: output.contextModification ?? "",
errorMessage: output.errorMessage ?? "",
}
}
/**
* Mock run method that records calls and returns preset response.
* Does not use the actual HookRunner execution mechanism.
*/
async run(params: NamedHookInput<Name>): Promise<HookOutput> {
// Validate params are serializable
if (!isSerializable(params)) {
throw new Error(
`MockHookRunner: Cannot clone non-serializable input. ` +
`Ensure all input values are primitive types, arrays, or plain objects.`,
)
}
// Use structuredClone for deep copy (Node 17+)
// Falls back to JSON stringify/parse for older Node versions
let clonedInput: NamedHookInput<Name>
try {
clonedInput = structuredClone(params)
} catch {
// Fallback for older Node versions
clonedInput = JSON.parse(JSON.stringify(params))
}
this.executionLog.push({
input: clonedInput,
timestamp: Date.now(),
})
// Simulate async execution
await new Promise((resolve) => setTimeout(resolve, 1))
return this.response
}
/**
* Assert this hook was called a specific number of times.
*
* @param times Expected number of calls
*/
assertCalled(times: number): void {
if (this.executionLog.length !== times) {
throw new Error(
`MockHookRunner call count assertion failed:\n` +
` Expected: ${times} calls\n` +
` Received: ${this.executionLog.length} calls\n` +
` Execution log:\n${JSON.stringify(this.executionLog, null, 2)}`,
)
}
}
/**
* Assert this hook was called with matching input.
* Performs partial match on the input object using deep equality.
* Property ordering does not affect equality checks.
* Uses should.js's eql() for robust deep equality comparison.
*
* @param matcher Partial input to match against
*/
assertCalledWith(matcher: Partial<NamedHookInput<Name>>): void {
const matchingCalls = this.executionLog.filter((log) => {
return Object.keys(matcher).every((key) => {
const matcherValue = (matcher as any)[key]
const logValue = (log.input as any)[key]
// Use should.js's eql() for deep equality (handles property ordering)
try {
should(logValue).eql(matcherValue)
return true
} catch {
return false
}
})
})
if (matchingCalls.length === 0) {
throw new Error(
`MockHookRunner input assertion failed - no calls matched the expected input:\n` +
` Expected input (partial): ${JSON.stringify(matcher, null, 2)}\n` +
` Actual calls: ${JSON.stringify(this.executionLog, null, 2)}`,
)
}
}
/**
* Reset all recorded calls and responses.
*/
reset(): void {
this.executionLog = []
this.response = {
cancel: false,
contextModification: "",
errorMessage: "",
}
}
}
/**
* Copies a fixture to the test environment.
*
* @param fixtureName Path to fixture relative to fixtures directory (e.g., "hooks/pretooluse/success")
* @param destDir Destination directory (typically tempDir from test environment)
*
* @example
* await loadFixture("hooks/pretooluse/success", tempDir)
* // Hook is now available at tempDir/.clinerules/hooks/PreToolUse
*/
export async function loadFixture(fixtureName: string, destDir: string): Promise<void> {
const fixturesDir = path.join(__dirname, "fixtures")
const sourcePath = path.join(fixturesDir, fixtureName)
const destHooksDir = await createHooksDirectory(destDir)
// Copy all files from the fixture directory to the destination
const files = await fs.readdir(sourcePath)
for (const file of files) {
const sourceFile = path.join(sourcePath, file)
if (process.platform === "win32") {
const sourceContent = await fs.readFile(sourceFile, "utf-8")
await writeHookScriptForPlatform(path.join(destHooksDir, file), sourceContent)
} else {
const destFile = path.join(destHooksDir, file)
await fs.copyFile(sourceFile, destFile)
// Set executable permission (not needed on Windows)
const stats = await fs.stat(sourceFile)
await fs.chmod(destFile, stats.mode)
}
}
}
/**
* Creates an isolated hook test environment, loads a fixture into it, creates a runner,
* and guarantees cleanup once the callback completes.
*
* This is useful for fixture suites that want to iterate through multiple scenarios
* without sharing hook directories, discovery cache state, or filesystem artifacts
* between scenarios.
*/
export async function withFixtureRunner<Name extends HookName, TResult>(
hookName: Name,
fixtureName: string,
callback: (runner: Awaited<ReturnType<HookFactory["create"]>>, env: HookTestEnv) => Promise<TResult>,
): Promise<TResult>
export async function withFixtureRunner<Name extends HookName, TResult>(
hookName: Name,
fixtureName: string,
env: HookTestEnv,
callback: (runner: Awaited<ReturnType<HookFactory["create"]>>, env: HookTestEnv) => Promise<TResult>,
): Promise<TResult>
export async function withFixtureRunner<Name extends HookName, TResult>(
hookName: Name,
fixtureName: string,
envOrCallback: HookTestEnv | ((runner: Awaited<ReturnType<HookFactory["create"]>>, env: HookTestEnv) => Promise<TResult>),
maybeCallback?: (runner: Awaited<ReturnType<HookFactory["create"]>>, env: HookTestEnv) => Promise<TResult>,
): Promise<TResult> {
const usingExistingEnv = typeof envOrCallback !== "function"
const env = usingExistingEnv ? envOrCallback : await createHookTestEnv()
const runCallback = usingExistingEnv ? maybeCallback : envOrCallback
if (!runCallback) {
throw new Error("withFixtureRunner requires a callback")
}
try {
await fs.rm(env.hooksDir, { recursive: true, force: true })
await createHooksDirectory(env.tempDir)
resetHookCache()
await loadFixture(fixtureName, env.tempDir)
const factory = new HookFactory()
const runner = await factory.create(hookName)
return await runCallback(runner, env)
} finally {
if (!usingExistingEnv) {
await env.cleanup()
}
}
}
@@ -1,494 +0,0 @@
import { afterEach, beforeEach, describe, it } from "bun:test"
import "should"
import fs from "fs/promises"
import path from "path"
import sinon from "sinon"
import { HookOutput } from "../../../shared/proto/cline/hooks"
import { HookFactory } from "../hook-factory"
import { createHookTestEnv, HookTestEnv, stubHookDirs, withFixtureRunner, writeHookScriptForPlatform } from "./test-utils"
describe("UserPromptSubmit Hook", () => {
let tempDir: string
let sandbox: sinon.SinonSandbox
let hookTestEnv: HookTestEnv
const WINDOWS_HOOK_TEST_TIMEOUT_MS = 15000
type FixtureScenario = {
fixtureName: string
prompt: string
assert: (result: HookOutput) => void
}
const getErrorMessage = (error: unknown): string => (error instanceof Error ? error.message : String(error))
const writeHookScript = async (hookPath: string, nodeScript: string): Promise<void> => {
await writeHookScriptForPlatform(hookPath, nodeScript)
}
beforeEach(async () => {
hookTestEnv = await createHookTestEnv()
tempDir = hookTestEnv.tempDir
sandbox = hookTestEnv.sandbox
})
afterEach(async () => {
await hookTestEnv.cleanup()
})
describe("Hook Input Format", () => {
it("should receive prompt text from user content", async () => {
const hookPath = path.join(tempDir, ".clinerules", "hooks", "UserPromptSubmit")
const hookScript = `#!/usr/bin/env node
const input = JSON.parse(require('fs').readFileSync(0, 'utf-8'));
const hasPrompt = input.userPromptSubmit && typeof input.userPromptSubmit.prompt === 'string' && input.userPromptSubmit.prompt.length > 0;
console.log(JSON.stringify({
cancel: false,
contextModification: hasPrompt ? "Received prompt" : "Missing prompt"
}))`
await writeHookScript(hookPath, hookScript)
const factory = new HookFactory()
const runner = await factory.create("UserPromptSubmit")
const result = await runner.run({
taskId: "test-task",
userPromptSubmit: {
prompt: "Create a todo app",
attachments: [],
},
})
result.cancel.should.be.false()
result.contextModification?.should.equal("Received prompt")
}, 5000)
it("should handle multiline prompts", async () => {
const hookPath = path.join(tempDir, ".clinerules", "hooks", "UserPromptSubmit")
const hookScript = `#!/usr/bin/env node
const input = JSON.parse(require('fs').readFileSync(0, 'utf-8'));
const lineCount = (input.userPromptSubmit.prompt.match(/\\n/g) || []).length + 1;
console.log(JSON.stringify({
cancel: false,
contextModification: "Line count: " + lineCount
}))`
await writeHookScript(hookPath, hookScript)
const factory = new HookFactory()
const runner = await factory.create("UserPromptSubmit")
const multilinePrompt = "Line 1\nLine 2\nLine 3"
const result = await runner.run({
taskId: "test-task",
userPromptSubmit: {
prompt: multilinePrompt,
attachments: [],
},
})
result.contextModification?.should.equal("Line count: 3")
})
it("should handle large prompts", async () => {
const hookPath = path.join(tempDir, ".clinerules", "hooks", "UserPromptSubmit")
const hookScript = `#!/usr/bin/env node
const input = JSON.parse(require('fs').readFileSync(0, 'utf-8'));
const size = input.userPromptSubmit.prompt.length;
console.log(JSON.stringify({
cancel: false,
contextModification: "Prompt size: " + size
}))`
await writeHookScript(hookPath, hookScript)
const factory = new HookFactory()
const runner = await factory.create("UserPromptSubmit")
const largePrompt = "x".repeat(10000)
const result = await runner.run({
taskId: "test-task",
userPromptSubmit: {
prompt: largePrompt,
attachments: [],
},
})
result.contextModification?.should.equal("Prompt size: 10000")
})
it("should receive all common hook input fields", async () => {
const hookPath = path.join(tempDir, ".clinerules", "hooks", "UserPromptSubmit")
const hookScript = `#!/usr/bin/env node
const input = JSON.parse(require('fs').readFileSync(0, 'utf-8'));
const hasAllFields = input.clineVersion && input.hookName && input.timestamp &&
input.taskId && input.workspaceRoots !== undefined &&
input.model && input.model.provider && input.model.slug;
console.log(JSON.stringify({
cancel: false,
contextModification: hasAllFields ? "All fields present" : "Missing fields"
}))`
await writeHookScript(hookPath, hookScript)
const factory = new HookFactory()
const runner = await factory.create("UserPromptSubmit")
const result = await runner.run({
taskId: "test-task",
userPromptSubmit: {
prompt: "Test",
attachments: [],
},
})
result.contextModification?.should.equal("All fields present")
})
})
describe("Prompt Content Serialization", () => {
it("should handle empty prompt", async () => {
const hookPath = path.join(tempDir, ".clinerules", "hooks", "UserPromptSubmit")
const hookScript = `#!/usr/bin/env node
const input = JSON.parse(require('fs').readFileSync(0, 'utf-8'));
const promptData = input.userPromptSubmit;
if (!promptData) {
console.log(JSON.stringify({
cancel: true,
errorMessage: "No userPromptSubmit data"
}));
process.exit(0);
}
const promptLength = typeof promptData.prompt === 'string' ? promptData.prompt.length : (promptData.prompt ? String(promptData.prompt).length : 0);
console.log(JSON.stringify({
cancel: false,
contextModification: "Prompt length: " + promptLength
}))`
await writeHookScript(hookPath, hookScript)
const factory = new HookFactory()
const runner = await factory.create("UserPromptSubmit")
const result = await runner.run({
taskId: "test-task",
userPromptSubmit: {
prompt: "",
attachments: [],
},
})
result.contextModification?.should.equal("Prompt length: 0")
})
it("should preserve special characters in prompt", async () => {
const hookPath = path.join(tempDir, ".clinerules", "hooks", "UserPromptSubmit")
const hookScript = `#!/usr/bin/env node
const input = JSON.parse(require('fs').readFileSync(0, 'utf-8'));
const prompt = input.userPromptSubmit.prompt;
const hasSpecialChars = prompt.includes("@") && prompt.includes("#") && prompt.includes("$");
console.log(JSON.stringify({
cancel: false,
contextModification: hasSpecialChars ? "Special chars preserved" : "Missing special chars"
}))`
await writeHookScript(hookPath, hookScript)
const factory = new HookFactory()
const runner = await factory.create("UserPromptSubmit")
const result = await runner.run({
taskId: "test-task",
userPromptSubmit: {
prompt: "Test @user #feature $cost",
attachments: [],
},
})
result.contextModification?.should.equal("Special chars preserved")
})
})
describe("Error Handling", () => {
it("should handle malformed JSON output from hook", async () => {
const hookPath = path.join(tempDir, ".clinerules", "hooks", "UserPromptSubmit")
const hookScript = `#!/usr/bin/env node
console.log("not valid json")`
await writeHookScript(hookPath, hookScript)
const factory = new HookFactory()
const runner = await factory.create("UserPromptSubmit")
// When hook exits 0 but has malformed JSON, it returns success without context
const result = await runner.run({
taskId: "test-task",
userPromptSubmit: {
prompt: "Test",
attachments: [],
},
})
// Hook succeeded (exit 0) but couldn't parse JSON, so returns success without context
result.cancel.should.be.false()
;(result.contextModification === undefined || result.contextModification === "").should.be.true()
})
it("should handle hook script errors", async () => {
const hookPath = path.join(tempDir, ".clinerules", "hooks", "UserPromptSubmit")
const hookScript = `#!/usr/bin/env node
process.exit(1)`
await writeHookScript(hookPath, hookScript)
const factory = new HookFactory()
const runner = await factory.create("UserPromptSubmit")
try {
await runner.run({
taskId: "test-task",
userPromptSubmit: {
prompt: "Test",
attachments: [],
},
})
throw new Error("Should have thrown")
} catch (error: unknown) {
getErrorMessage(error).should.match(/exited with code 1/)
}
})
})
describe("Global and Workspace Hooks", () => {
let globalHooksDir: string
let workspaceHooksDir: string
beforeEach(async () => {
// Create global hooks directory
globalHooksDir = path.join(tempDir, "global-hooks")
await fs.mkdir(globalHooksDir, { recursive: true })
workspaceHooksDir = path.join(tempDir, ".clinerules", "hooks")
// Use deterministic hook directories to avoid test flakiness from
// calling real directory discovery logic in CI.
stubHookDirs(sandbox, [globalHooksDir, workspaceHooksDir])
})
it("should execute both global and workspace UserPromptSubmit hooks", async () => {
// Create global hook
const globalHookPath = path.join(globalHooksDir, "UserPromptSubmit")
const globalHookScript = `#!/usr/bin/env node
console.log(JSON.stringify({
cancel: false,
contextModification: "GLOBAL: Prompt received"
}))`
await writeHookScript(globalHookPath, globalHookScript)
// Create workspace hook
const workspaceHookPath = path.join(tempDir, ".clinerules", "hooks", "UserPromptSubmit")
const workspaceHookScript = `#!/usr/bin/env node
console.log(JSON.stringify({
cancel: false,
contextModification: "WORKSPACE: Prompt received"
}))`
await writeHookScript(workspaceHookPath, workspaceHookScript)
const factory = new HookFactory()
const runner = await factory.create("UserPromptSubmit")
const result = await runner.run({
taskId: "test-task",
userPromptSubmit: {
prompt: "Create a feature",
attachments: [],
},
})
result.cancel.should.be.false()
result.contextModification?.should.match(/GLOBAL: Prompt received/)
result.contextModification?.should.match(/WORKSPACE: Prompt received/)
})
it("should block if workspace hook blocks even when global allows", async () => {
// Create allowing global hook
const globalHookPath = path.join(globalHooksDir, "UserPromptSubmit")
const globalHookScript = `#!/usr/bin/env node
console.log(JSON.stringify({
cancel: false,
contextModification: "Global allows"
}))`
await writeHookScript(globalHookPath, globalHookScript)
// Create blocking workspace hook
const workspaceHookPath = path.join(tempDir, ".clinerules", "hooks", "UserPromptSubmit")
const workspaceHookScript = `#!/usr/bin/env node
console.log(JSON.stringify({
cancel: true,
errorMessage: "Workspace blocks"
}))`
await writeHookScript(workspaceHookPath, workspaceHookScript)
const factory = new HookFactory()
const runner = await factory.create("UserPromptSubmit")
const result = await runner.run({
taskId: "test-task",
userPromptSubmit: {
prompt: "Create a feature",
attachments: [],
},
})
result.cancel.should.be.true()
result.errorMessage?.should.match(/Workspace blocks/)
})
})
describe("No Hook Behavior", () => {
it("should allow prompt when no hook exists", async () => {
// Don't create any hook
const factory = new HookFactory()
const runner = await factory.create("UserPromptSubmit")
const result = await runner.run({
taskId: "test-task",
userPromptSubmit: {
prompt: "Create a feature",
attachments: [],
},
})
// NoOpRunner always returns success
result.cancel.should.be.false()
})
})
describe("Fixture-Based Tests", () => {
// These tests demonstrate using pre-written fixtures from the fixtures directory
// Fixtures serve as both test data and examples for manual testing
const isWindows = process.platform === "win32"
it(
"should validate representative fixtures end-to-end",
async () => {
// Multiple fixture scenarios spawn child processes sequentially,
// which can easily exceed the default 2 s Mocha timeout.
const scenarios: FixtureScenario[] = [
{
fixtureName: "success",
prompt: "Create a feature",
assert: (result: HookOutput) => {
result.cancel.should.be.false()
result.contextModification?.should.equal("Prompt approved")
},
},
{
fixtureName: "blocking",
prompt: "Do something forbidden",
assert: (result: HookOutput) => {
result.cancel.should.be.true()
result.errorMessage?.should.equal("Prompt violates policy")
},
},
{
fixtureName: "context-injection",
prompt: "Build something",
assert: (result: HookOutput) => {
result.cancel.should.be.false()
result.contextModification?.should.equal("CONTEXT_INJECTION: User is in plan mode")
},
},
{
fixtureName: "multiline",
prompt: "Line 1\nLine 2\nLine 3",
assert: (result: HookOutput) => {
result.cancel.should.be.false()
result.contextModification?.should.equal("Line count: 3")
},
},
{
fixtureName: "special-chars",
prompt: "Test @user #feature $cost",
assert: (result: HookOutput) => {
result.cancel.should.be.false()
result.contextModification?.should.equal("Special chars preserved")
},
},
{
fixtureName: "empty-prompt",
prompt: "",
assert: (result: HookOutput) => {
result.cancel.should.be.false()
result.contextModification?.should.equal("Prompt length: 0")
},
},
]
if (!isWindows) {
scenarios.push({
fixtureName: "large-prompt",
prompt: "x".repeat(10000),
assert: (result: HookOutput) => {
result.cancel.should.be.false()
result.contextModification?.should.equal("Prompt size: 10000")
},
})
}
for (const scenario of scenarios) {
await withFixtureRunner(
"UserPromptSubmit",
`hooks/userpromptsubmit/${scenario.fixtureName}`,
hookTestEnv,
async (runner) => {
const result = await runner.run({
taskId: "test-task",
userPromptSubmit: {
prompt: scenario.prompt,
attachments: [],
},
})
scenario.assert(result)
},
)
}
},
WINDOWS_HOOK_TEST_TIMEOUT_MS,
)
it("should cover malformed-json fixture path", async () => {
await withFixtureRunner("UserPromptSubmit", "hooks/userpromptsubmit/malformed-json", hookTestEnv, async (runner) => {
const malformedResult = await runner.run({
taskId: "test-task",
userPromptSubmit: {
prompt: "Test",
attachments: [],
},
})
malformedResult.cancel.should.be.false()
;(
malformedResult.contextModification === undefined || malformedResult.contextModification === ""
).should.be.true()
})
})
it("should cover failing fixture path", async () => {
await withFixtureRunner("UserPromptSubmit", "hooks/userpromptsubmit/error", hookTestEnv, async (runner) => {
try {
await runner.run({
taskId: "test-task",
userPromptSubmit: {
prompt: "Test",
attachments: [],
},
})
throw new Error("Should have thrown")
} catch (error: unknown) {
getErrorMessage(error).should.match(/exited with code 1/)
}
})
})
})
})
File diff suppressed because it is too large Load Diff
@@ -1,8 +0,0 @@
/**
* Computes the effective hooks-enabled state from persisted user setting.
*
* NOTE: This is the single choke point used by runtime and UI state shaping.
*/
export function getHooksEnabledSafe(userSetting: boolean | undefined): boolean {
return userSetting ?? true
}
@@ -1,67 +0,0 @@
/**
* Platform-specific shell escaping utilities for hook script paths.
* Ensures paths with spaces and special characters work correctly when
* executed through a shell (shell: true in spawn).
*/
/**
* Escapes a path for safe use in a Windows shell command.
* Handles spaces, quotes, and other special characters.
*
* Windows shell (cmd.exe) rules:
* - Wrap path in double quotes
* - Escape double quotes by doubling them ("")
* - Backslashes before quotes need to be doubled
*
* @param path The file path to escape
* @returns The escaped path safe for Windows shell execution
*/
function escapeWindowsShellPath(path: string): string {
// Escape backslashes that precede quotes
let escaped = path.replace(/\\"/g, '\\\\"')
// Escape standalone double quotes by doubling them
escaped = escaped.replace(/"/g, '""')
// Wrap in double quotes
return `"${escaped}"`
}
/**
* Escapes a path for safe use in a Unix shell command (sh, bash, zsh).
* Handles spaces, quotes, apostrophes, and other special characters.
*
* Unix shell rules:
* - Wrap path in single quotes (safest for most characters)
* - Single quotes inside path are escaped as '\''
* (close quote, escaped quote, open quote)
*
* @param path The file path to escape
* @returns The escaped path safe for Unix shell execution
*/
function escapeUnixShellPath(path: string): string {
// Replace single quotes with '\'' (close quote, escaped quote, open quote)
const escaped = path.replace(/'/g, "'\\''")
// Wrap in single quotes
return `'${escaped}'`
}
/**
* Escapes a file path for safe shell execution on any platform.
* This is critical when using spawn() with shell: true and paths that
* may contain spaces or special characters.
*
* Use cases:
* - Global hooks directory: ~/Documents/Cline/Hooks/
* - Workspace hooks: /path/to/My Project/.clinerules/hooks/
* - Multi-root workspaces: each root's .clinerules/hooks/
*
* Examples:
* - "/Users/user/My Project/hooks/PreToolUse" → "'/Users/user/My Project/hooks/PreToolUse'"
* - "C:\Users\user\My Project\hooks\PreToolUse" → '"C:\Users\user\My Project\hooks\PreToolUse"'
* - "/path/with 'quotes'/hooks/PreToolUse" → "'/path/with '\''quotes'\'' /hooks/PreToolUse'"
*
* @param path The file path to escape
* @returns The escaped path safe for shell execution on the current platform
*/
export function escapeShellPath(path: string): string {
return process.platform === "win32" ? escapeWindowsShellPath(path) : escapeUnixShellPath(path)
}
-494
View File
@@ -1,494 +0,0 @@
/**
* Hook script templates for all supported hook types.
* On Unix, templates are Bash scripts with comprehensive examples.
* On Windows, templates are PowerShell scripts executed by the Windows hook runtime.
*/
export function getHookTemplate(hookName: string): string {
if (process.platform === "win32") {
return getWindowsPowerShellTemplate(hookName)
}
const templates: Record<string, string> = {
TaskStart: getTaskStartTemplate(),
TaskResume: getTaskResumeTemplate(),
TaskCancel: getTaskCancelTemplate(),
TaskComplete: getTaskCompleteTemplate(),
PreToolUse: getPreToolUseTemplate(),
PostToolUse: getPostToolUseTemplate(),
UserPromptSubmit: getUserPromptSubmitTemplate(),
Notification: getNotificationTemplate(),
PreCompact: getPreCompactTemplate(),
}
return templates[hookName] || getDefaultTemplate(hookName)
}
function getWindowsPowerShellTemplate(hookName: string): string {
return `# ${hookName} Hook
# PowerShell template for Windows hook execution.
try {
$rawInput = [Console]::In.ReadToEnd()
if ($rawInput) {
$null = $rawInput | ConvertFrom-Json
}
} catch {
Write-Error "[${hookName}] Invalid JSON input: $($_.Exception.Message)"
}
@{
cancel = $false
contextModification = ""
errorMessage = ""
} | ConvertTo-Json -Compress
`
}
function getTaskStartTemplate(): string {
return `#!/bin/bash
#
# TaskStart Hook
#
# Executes when a new task begins.
#
# Input: {
# taskId,
# taskStart: {
# taskMetadata: { taskId: string, ulid: string, initialTask: string }
# },
# clineVersion, timestamp, ...
# }
# Output: { cancel: boolean, contextModification?: string, errorMessage?: string }
#
# Use cases:
# - Log task start time
# - Add context about environment or project state
# - Check prerequisites before starting
# - Notify external systems (Slack, issue trackers, etc.)
# Read JSON input from stdin
INPUT=$(cat)
# Parse input using jq (or fallback to basic parsing)
if command -v jq &> /dev/null; then
TASK=$(echo "$INPUT" | jq -r '.taskStart.taskMetadata.initialTask')
TASK_ID=$(echo "$INPUT" | jq -r '.taskId')
TIMESTAMP=$(echo "$INPUT" | jq -r '.timestamp')
else
# Fallback if jq is not available
TASK="<task>"
TASK_ID="<taskId>"
TIMESTAMP=$(date +%s%3N)
fi
# Example: Log task start
echo "[TaskStart] Task started: $TASK" >&2
echo "[TaskStart] Task ID: $TASK_ID" >&2
# Example: Add context to the task
TIMESTAMP_ISO=$(date -u -d @"$((TIMESTAMP/1000))" +"%Y-%m-%dT%H:%M:%SZ" 2>/dev/null || date -u +"%Y-%m-%dT%H:%M:%SZ")
CONTEXT_MOD="Note: Task started at $TIMESTAMP_ISO"
# Return result as JSON (use jq to safely encode the variable, with a simple fallback)
if command -v jq &> /dev/null; then
jq -n --arg ctx "$CONTEXT_MOD" '{"cancel":false,"contextModification":$ctx,"errorMessage":""}'
else
ESCAPED_MOD=$(printf '%s' "$CONTEXT_MOD" | sed 's/\\\\/\\\\\\\\/g; s/"/\\\\"/g')
echo '{"cancel":false,"contextModification":"'"$ESCAPED_MOD"'","errorMessage":""}'
fi
`
}
function getTaskResumeTemplate(): string {
return `#!/bin/bash
#
# TaskResume Hook
#
# Executes when a task is resumed after being interrupted.
#
# Input: {
# taskId,
# taskResume: {
# taskMetadata: { taskId: string, ulid: string },
# previousState: { lastMessageTs: string, messageCount: string, conversationHistoryDeleted: string }
# },
# clineVersion, timestamp, ...
# }
# Output: { cancel: boolean, contextModification?: string, errorMessage?: string }
#
# Use cases:
# - Check for changes since task was paused
# - Refresh context with latest project state
# - Notify that work is resuming
# Read JSON input from stdin
INPUT=$(cat)
# Parse input using jq (or fallback to basic parsing)
if command -v jq &> /dev/null; then
TASK_ID=$(echo "$INPUT" | jq -r '.taskResume.taskMetadata.taskId')
MSG_COUNT=$(echo "$INPUT" | jq -r '.taskResume.previousState.messageCount')
else
TASK_ID="<taskId>"
MSG_COUNT="0"
fi
echo "[TaskResume] Resuming task: $TASK_ID (previous messages: $MSG_COUNT)" >&2
# Return result
echo '{"cancel":false,"contextModification":"","errorMessage":""}'
`
}
function getTaskCancelTemplate(): string {
return `#!/bin/bash
#
# TaskCancel Hook
#
# Executes when a task is cancelled by the user.
#
# Input: {
# taskId,
# taskCancel: {
# taskMetadata: { taskId: string, ulid: string, completionStatus: string }
# },
# clineVersion, timestamp, ...
# }
# Output: { cancel: boolean, contextModification?: string, errorMessage?: string }
#
# Use cases:
# - Clean up temporary files or resources
# - Notify external systems about cancellation
# - Log cancellation for analytics
# Read JSON input from stdin
INPUT=$(cat)
# Parse input using jq (or fallback to basic parsing)
if command -v jq &> /dev/null; then
TASK_ID=$(echo "$INPUT" | jq -r '.taskCancel.taskMetadata.taskId')
STATUS=$(echo "$INPUT" | jq -r '.taskCancel.taskMetadata.completionStatus')
else
TASK_ID="<taskId>"
STATUS="cancelled"
fi
echo "[TaskCancel] Task cancelled: $TASK_ID (status: $STATUS)" >&2
# Return result
echo '{"cancel":false,"contextModification":"","errorMessage":""}'
`
}
function getTaskCompleteTemplate(): string {
return `#!/bin/bash
#
# TaskComplete Hook
#
# Executes when a task completes successfully.
#
# Input: {
# taskId,
# taskComplete: {
# taskMetadata: { taskId: string, ulid: string, result: string, command: string }
# },
# clineVersion, timestamp, ...
# }
# Output: { cancel: boolean, contextModification?: string, errorMessage?: string }
#
# Use cases:
# - Run tests or validation
# - Generate reports or summaries
# - Notify stakeholders
# - Trigger CI/CD pipelines
# Read JSON input from stdin
INPUT=$(cat)
# Parse input using jq (or fallback to basic parsing)
if command -v jq &> /dev/null; then
TASK_ID=$(echo "$INPUT" | jq -r '.taskComplete.taskMetadata.taskId')
RESULT=$(echo "$INPUT" | jq -r '.taskComplete.taskMetadata.result')
else
TASK_ID="<taskId>"
RESULT="<result>"
fi
echo "[TaskComplete] Task completed: $TASK_ID (result: $RESULT)" >&2
# Return result
echo '{"cancel":false,"contextModification":"","errorMessage":""}'
`
}
function getPreToolUseTemplate(): string {
return `#!/bin/bash
#
# PreToolUse Hook
#
# Executes before any tool is used (read_file, write_to_file, execute_command, etc.)
#
# Input: { taskId, preToolUse: { toolName: string, parameters: object }, ... }
# Output: { cancel: boolean, contextModification?: string, errorMessage?: string }
#
# Use cases:
# - Block dangerous operations
# - Add safety checks before file modifications
# - Log tool usage
# - Validate parameters before execution
# Read JSON input from stdin
INPUT=$(cat)
# Parse input using jq (or fallback to basic parsing)
if command -v jq &> /dev/null; then
TOOL=$(echo "$INPUT" | jq -r '.preToolUse.toolName')
COMMAND=$(echo "$INPUT" | jq -r '.preToolUse.parameters.command // empty')
else
TOOL="<tool>"
COMMAND=""
fi
# Example: Block dangerous operations
if [[ "$TOOL" == "execute_command" ]] && [[ "$COMMAND" == *"rm -rf /"* ]]; then
echo '{"cancel":true,"errorMessage":"Dangerous command blocked by PreToolUse hook"}'
exit 0
fi
# Example: Log tool usage
echo "[PreToolUse] Tool about to execute: $TOOL" >&2
# Allow execution
echo '{"cancel":false,"contextModification":"","errorMessage":""}'
`
}
function getPostToolUseTemplate(): string {
return `#!/bin/bash
#
# PostToolUse Hook
#
# Executes after any tool is used successfully or fails.
#
# Input: {
# taskId,
# postToolUse: {
# toolName: string,
# parameters: object,
# result: string,
# success: boolean,
# executionTimeMs: number
# },
# ...
# }
# Output: { cancel: boolean, contextModification?: string, errorMessage?: string }
#
# Use cases:
# - Audit tool usage
# - Validate results
# - Trigger follow-up actions
# - Monitor performance
# Read JSON input from stdin
INPUT=$(cat)
# Parse input using jq (or fallback to basic parsing)
if command -v jq &> /dev/null; then
TOOL=$(echo "$INPUT" | jq -r '.postToolUse.toolName')
SUCCESS=$(echo "$INPUT" | jq -r '.postToolUse.success')
DURATION=$(echo "$INPUT" | jq -r '.postToolUse.executionTimeMs')
else
TOOL="<tool>"
SUCCESS="true"
DURATION="0"
fi
# Log tool completion
STATUS="success"
[[ "$SUCCESS" == "false" ]] && STATUS="failed"
echo "[PostToolUse] Tool completed: $TOOL ($STATUS) in \${DURATION}ms" >&2
# Return result
echo '{"cancel":false,"contextModification":"","errorMessage":""}'
`
}
function getUserPromptSubmitTemplate(): string {
return `#!/bin/bash
#
# UserPromptSubmit Hook
#
# Executes when the user submits a prompt to Cline.
#
# Input: { taskId, userPromptSubmit: { prompt: string, attachments: string[] }, clineVersion, timestamp, ... }
# Output: { cancel: boolean, contextModification?: string, errorMessage?: string }
#
# Use cases:
# - Log user prompts for analytics
# - Add context based on prompt content
# - Validate or sanitize prompts
# - Trigger external integrations
# Read JSON input from stdin
INPUT=$(cat)
# Parse input using jq (or fallback to basic parsing)
if command -v jq &> /dev/null; then
PROMPT=$(echo "$INPUT" | jq -r '.userPromptSubmit.prompt')
PROMPT_LENGTH=\${#PROMPT}
else
PROMPT_LENGTH=0
fi
echo "[UserPromptSubmit] User submitted prompt (length: $PROMPT_LENGTH)" >&2
# Return result
echo '{"cancel":false,"contextModification":"","errorMessage":""}'
`
}
function getNotificationTemplate(): string {
return `#!/bin/bash
#
# Notification Hook
#
# Executes when Cline reaches a user-attention boundary or emits lifecycle notifications.
#
# Input: {
# taskId,
# notification: {
# event: string,
# source: string,
# message: string,
# waitingForUserInput: boolean,
# eventVersion: string,
# eventId: string,
# messageTruncated: boolean,
# sourceType: string,
# sourceId: string,
# requiresUserAction: boolean,
# severity: string
# },
# clineVersion,
# timestamp,
# ...
# }
# Output: { cancel: boolean, contextModification?: string, errorMessage?: string }
#
# Typical events:
# - user_attention (ask prompt requiring user input)
# - task_complete (task reached completion)
#
# Notification hooks are observation-only:
# - cancel is ignored by the caller
# - contextModification is ignored by the caller
# - hook failures are non-fatal
INPUT=$(cat)
if command -v jq &> /dev/null; then
EVENT=$(echo "$INPUT" | jq -r '.notification.event // "unknown"')
SOURCE=$(echo "$INPUT" | jq -r '.notification.source // "unknown"')
WAITING=$(echo "$INPUT" | jq -r '.notification.waitingForUserInput // false')
EVENT_VERSION=$(echo "$INPUT" | jq -r '.notification.eventVersion // "unknown"')
SOURCE_TYPE=$(echo "$INPUT" | jq -r '.notification.sourceType // "unknown"')
REQUIRES_ACTION=$(echo "$INPUT" | jq -r '.notification.requiresUserAction // false')
SEVERITY=$(echo "$INPUT" | jq -r '.notification.severity // "info"')
else
EVENT="unknown"
SOURCE="unknown"
WAITING="false"
EVENT_VERSION="unknown"
SOURCE_TYPE="unknown"
REQUIRES_ACTION="false"
SEVERITY="info"
fi
echo "[Notification] event=$EVENT source=$SOURCE sourceType=$SOURCE_TYPE waitingForUserInput=$WAITING requiresUserAction=$REQUIRES_ACTION severity=$SEVERITY eventVersion=$EVENT_VERSION" >&2
echo '{"cancel":false,"contextModification":"","errorMessage":""}'
`
}
function getPreCompactTemplate(): string {
return `#!/bin/bash
#
# PreCompact Hook
#
# Executes before conversation context is compacted (to free up token space).
#
# Input: {
# taskId,
# preCompact: {
# taskId: string,
# ulid: string,
# contextSize: number,
# compactionStrategy: string,
# previousApiReqIndex: number,
# tokensIn: number,
# tokensOut: number,
# tokensInCache: number,
# tokensOutCache: number,
# deletedRangeStart: number,
# deletedRangeEnd: number,
# contextJsonPath: string,
# contextRawPath: string
# },
# ...
# }
# Output: { cancel: boolean, contextModification?: string, errorMessage?: string }
#
# Use cases:
# - Archive important conversation parts
# - Log compaction events
# - Add summary before context is lost
# Read JSON input from stdin
INPUT=$(cat)
# Parse input using jq (or fallback to basic parsing)
if command -v jq &> /dev/null; then
CONTEXT_SIZE=$(echo "$INPUT" | jq -r '.preCompact.contextSize')
STRATEGY=$(echo "$INPUT" | jq -r '.preCompact.compactionStrategy')
TOKENS_IN=$(echo "$INPUT" | jq -r '.preCompact.tokensIn')
TOKENS_OUT=$(echo "$INPUT" | jq -r '.preCompact.tokensOut')
else
CONTEXT_SIZE="<size>"
STRATEGY="<strategy>"
TOKENS_IN="<tokens>"
TOKENS_OUT="<tokens>"
fi
echo "[PreCompact] About to compact conversation (contextSize: $CONTEXT_SIZE, strategy: $STRATEGY, tokensIn: $TOKENS_IN, tokensOut: $TOKENS_OUT)" >&2
# Return result
echo '{"cancel":false,"contextModification":"","errorMessage":""}'
`
}
function getDefaultTemplate(hookName: string): string {
return `#!/bin/bash
#
# ${hookName} Hook
#
# Input: JSON via stdin
# Output: JSON to stdout
# Read JSON input from stdin
INPUT=$(cat)
# Parse input using jq (or fallback to basic parsing)
if command -v jq &> /dev/null; then
TASK_ID=$(echo "$INPUT" | jq -r '.taskId')
else
TASK_ID="<taskId>"
fi
# Your hook logic here
echo "[${hookName}] Executed for task $TASK_ID" >&2
# Return result
echo '{"cancel":false,"contextModification":"","errorMessage":""}'
`
}
-114
View File
@@ -1,114 +0,0 @@
import fs from "fs/promises"
import os from "os"
import path from "path"
import { HostProvider } from "@/hosts/host-provider"
import { getCwd, getDesktopDir } from "@/utils/path"
/**
* All valid hook types that can be created and executed by Cline.
* These hooks correspond to specific lifecycle events in the task execution process.
*/
export const VALID_HOOK_TYPES = [
"TaskStart",
"TaskResume",
"TaskCancel",
"TaskComplete",
"PreToolUse",
"PostToolUse",
"UserPromptSubmit",
"Notification",
"PreCompact",
] as const
/**
* Type representing a valid hook name
*/
export type HookType = (typeof VALID_HOOK_TYPES)[number]
/**
* Validates if a given hook name is a valid hook type.
*
* @param hookName - The hook name to validate
* @returns True if the hook name is valid, false otherwise
*/
export function isValidHookType(hookName: string): hookName is HookType {
return VALID_HOOK_TYPES.includes(hookName as HookType)
}
/**
* Resolves the hooks directory path for either global or workspace hooks.
* Handles both single and multi-root workspaces.
*
* @param isGlobal - Whether to resolve the global hooks directory
* @param workspaceName - For multi-root workspaces, the name of the specific workspace
* @param globalHooksDirOverride - Optional override for global hooks directory (for testing)
* @returns The absolute path to the hooks directory
* @throws Error if the specified workspace cannot be found
*/
export async function resolveHooksDirectory(
isGlobal: boolean,
workspaceName?: string,
globalHooksDirOverride?: string,
): Promise<string> {
if (isGlobal) {
return globalHooksDirOverride || path.join(os.homedir(), "Documents", "Cline", "Hooks")
}
// For workspace hooks, find the correct workspace
if (workspaceName) {
// Multi-root workspace: find the workspace with this name
const workspacePaths = await HostProvider.workspace.getWorkspacePaths({})
const targetWorkspace = workspacePaths.paths.find((p) => path.basename(p) === workspaceName)
if (!targetWorkspace) {
throw new Error(`Workspace "${workspaceName}" not found`)
}
return path.join(targetWorkspace, ".clinerules", "hooks")
}
// Single workspace: use getCwd
const cwd = await getCwd(getDesktopDir())
return path.join(cwd, ".clinerules", "hooks")
}
/**
* Resolves the active hook file path for a given hook name.
*
* Platform-specific filename rules are intentionally strict:
*
* On Windows, only PowerShell-native naming is supported:
* - <HookName>.ps1
*
* Why: Windows hooks execute via PowerShell (`powershell -File ...`).
* PowerShell cannot execute bash-style extensionless hook files as-is.
*
* On Unix-like platforms (Linux/macOS), only canonical extensionless names are considered:
* - <HookName>
*
* Why: Unix hooks are discovered/executed as native executable files
* (bash scripts, binaries, etc.) using executable-bit semantics.
* `.ps1` files are not part of the supported Unix hook contract.
*
* @param hooksDir Directory containing hook files
* @param hookName Hook type/name to resolve
* @returns Resolved absolute file path if present, otherwise undefined
*/
export async function resolveExistingHookPath(hooksDir: string, hookName: string): Promise<string | undefined> {
const candidates = process.platform === "win32" ? [path.join(hooksDir, `${hookName}.ps1`)] : [path.join(hooksDir, hookName)]
for (const candidate of candidates) {
if (await isRegularFile(candidate)) {
return candidate
}
}
return undefined
}
async function isRegularFile(filePath: string): Promise<boolean> {
try {
const stat = await fs.stat(filePath)
return stat.isFile()
} catch {
return false
}
}
@@ -1,283 +0,0 @@
import { afterAll, afterEach, beforeAll, beforeEach, describe, it, mock } from "bun:test"
import "should"
import * as actualFsUtils from "@utils/fs"
import fs from "fs/promises"
import os from "os"
import path from "path"
import sinon from "sinon"
import { HostProvider } from "@/hosts/host-provider"
import { setVscodeHostProviderMock } from "@/test/host-provider-test-utils"
// bun loads real ESM, so sinon cannot stub the `@utils/fs` namespace export
// ("ES Modules cannot be stubbed"). Inject a module-level sinon stub for
// `isDirectory` via mock.module so the full sinon stub API keeps working. It
// defaults to the real implementation; only the error-propagation test overrides
// it. Register both the alias form and the SUT's relative form.
const realIsDirectory = actualFsUtils.isDirectory
const isDirectoryStub: sinon.SinonStub = sinon.stub()
const fsUtilsMock = () => ({ ...actualFsUtils, isDirectory: isDirectoryStub })
mock.module("@utils/fs", fsUtilsMock)
mock.module("@/utils/fs", fsUtilsMock)
import { getAllHooksDirs, getWorkspaceHooksDirs, setRuntimeHooksDir } from "../disk"
import { StateManager } from "../StateManager"
describe("disk - hooks functionality", () => {
let sandbox: sinon.SinonSandbox
let tempDir: string
beforeEach(async () => {
sandbox = sinon.createSandbox()
// Default the module-level isDirectory stub to the real implementation;
// individual tests override it as needed.
isDirectoryStub.reset()
isDirectoryStub.callsFake((...args: unknown[]) => (realIsDirectory as (...a: unknown[]) => Promise<boolean>)(...args))
tempDir = path.join(os.tmpdir(), `disk-test-${Date.now()}-${Math.random().toString(36).slice(2)}`)
await fs.mkdir(tempDir, { recursive: true })
})
afterEach(async () => {
sandbox.restore()
setRuntimeHooksDir(undefined)
try {
await fs.rm(tempDir, { recursive: true, force: true })
} catch (_error) {
// Ignore cleanup errors
}
})
describe("getWorkspaceHooksDirs", () => {
it("should return empty array when no workspace roots exist", async () => {
sandbox.stub(StateManager, "get").returns({
getGlobalStateKey: () => undefined,
} as any)
const result = await getWorkspaceHooksDirs()
result.should.be.an.Array()
result.length.should.equal(0)
})
it("should return empty array when workspace roots is empty array", async () => {
sandbox.stub(StateManager, "get").returns({
getGlobalStateKey: () => [],
} as any)
const result = await getWorkspaceHooksDirs()
result.should.be.an.Array()
result.length.should.equal(0)
})
it("should return empty array when no hooks directories exist", async () => {
// Create workspace root without hooks directory
const workspaceRoot = path.join(tempDir, "workspace1")
await fs.mkdir(workspaceRoot, { recursive: true })
sandbox.stub(StateManager, "get").returns({
getGlobalStateKey: () => [{ path: workspaceRoot }],
} as any)
const result = await getWorkspaceHooksDirs()
result.should.be.an.Array()
result.length.should.equal(0)
})
it("should return hooks directory when it exists", async () => {
// Create workspace root with hooks directory
const workspaceRoot = path.join(tempDir, "workspace1")
const hooksDir = path.join(workspaceRoot, ".clinerules", "hooks")
await fs.mkdir(hooksDir, { recursive: true })
sandbox.stub(StateManager, "get").returns({
getGlobalStateKey: () => [{ path: workspaceRoot }],
} as any)
const result = await getWorkspaceHooksDirs()
result.should.be.an.Array()
result.length.should.equal(1)
result[0].should.equal(hooksDir)
})
it("should not return hooks directory if it's a file instead of directory", async () => {
// Create workspace root with hooks as a file (not directory)
const workspaceRoot = path.join(tempDir, "workspace1")
const hooksPath = path.join(workspaceRoot, ".clinerules", "hooks")
await fs.mkdir(path.dirname(hooksPath), { recursive: true })
await fs.writeFile(hooksPath, "not a directory")
sandbox.stub(StateManager, "get").returns({
getGlobalStateKey: () => [{ path: workspaceRoot }],
} as any)
const result = await getWorkspaceHooksDirs()
result.should.be.an.Array()
result.length.should.equal(0)
})
it("should return multiple hooks directories for multi-root workspace", async () => {
// Create multiple workspace roots with hooks directories
const workspaceRoot1 = path.join(tempDir, "workspace1")
const workspaceRoot2 = path.join(tempDir, "workspace2")
const hooksDir1 = path.join(workspaceRoot1, ".clinerules", "hooks")
const hooksDir2 = path.join(workspaceRoot2, ".clinerules", "hooks")
await fs.mkdir(hooksDir1, { recursive: true })
await fs.mkdir(hooksDir2, { recursive: true })
sandbox.stub(StateManager, "get").returns({
getGlobalStateKey: () => [{ path: workspaceRoot1 }, { path: workspaceRoot2 }],
} as any)
const result = await getWorkspaceHooksDirs()
result.should.be.an.Array()
result.length.should.equal(2)
result.should.containEql(hooksDir1)
result.should.containEql(hooksDir2)
})
it("should return only existing hooks directories in multi-root workspace", async () => {
// Create multiple workspace roots, but only some have hooks directories
const workspaceRoot1 = path.join(tempDir, "workspace1")
const workspaceRoot2 = path.join(tempDir, "workspace2")
const workspaceRoot3 = path.join(tempDir, "workspace3")
const hooksDir1 = path.join(workspaceRoot1, ".clinerules", "hooks")
const hooksDir3 = path.join(workspaceRoot3, ".clinerules", "hooks")
await fs.mkdir(hooksDir1, { recursive: true })
await fs.mkdir(workspaceRoot2, { recursive: true }) // No hooks dir
await fs.mkdir(hooksDir3, { recursive: true })
sandbox.stub(StateManager, "get").returns({
getGlobalStateKey: () => [{ path: workspaceRoot1 }, { path: workspaceRoot2 }, { path: workspaceRoot3 }],
} as any)
const result = await getWorkspaceHooksDirs()
result.should.be.an.Array()
result.length.should.equal(2)
result.should.containEql(hooksDir1)
result.should.containEql(hooksDir3)
result.should.not.containEql(path.join(workspaceRoot2, ".clinerules", "hooks"))
})
it("should propagate errors when checking directory fails", async () => {
const workspaceRoot = path.join(tempDir, "workspace1")
await fs.mkdir(workspaceRoot, { recursive: true })
sandbox.stub(StateManager, "get").returns({
getGlobalStateKey: () => [{ path: workspaceRoot }],
} as any)
// Stub isDirectory to throw an error
isDirectoryStub.rejects(new Error("Permission denied"))
// Should propagate the error
try {
await getWorkspaceHooksDirs()
throw new Error("Should have thrown")
} catch (error: any) {
error.message.should.equal("Permission denied")
}
})
it("should use correct path joining for hooks directory", async () => {
const workspaceRoot = path.join(tempDir, "workspace1")
const expectedHooksDir = path.join(workspaceRoot, ".clinerules", "hooks")
await fs.mkdir(expectedHooksDir, { recursive: true })
sandbox.stub(StateManager, "get").returns({
getGlobalStateKey: () => [{ path: workspaceRoot }],
} as any)
const result = await getWorkspaceHooksDirs()
result[0].should.equal(expectedHooksDir)
// Verify it uses the correct path separator for the platform
result[0].should.match(/\.clinerules[\\/]hooks$/)
})
it("should handle workspace roots with trailing slashes", async () => {
const workspaceRoot = path.join(tempDir, "workspace1")
const workspaceRootWithSlash = workspaceRoot + path.sep
const hooksDir = path.join(workspaceRoot, ".clinerules", "hooks")
await fs.mkdir(hooksDir, { recursive: true })
sandbox.stub(StateManager, "get").returns({
getGlobalStateKey: () => [{ path: workspaceRootWithSlash }],
} as any)
const result = await getWorkspaceHooksDirs()
result.should.be.an.Array()
result.length.should.equal(1)
result[0].should.equal(hooksDir)
})
})
describe("getAllHooksDirs", () => {
it("should include the runtime hooks directory when it exists", async () => {
const runtimeHooksDir = path.join(tempDir, "runtime-hooks")
await fs.mkdir(runtimeHooksDir, { recursive: true })
sandbox.stub(os, "homedir").returns(tempDir)
sandbox.stub(StateManager, "get").returns({
getGlobalStateKey: () => [],
} as any)
isDirectoryStub.callsFake(async (targetPath: string) => targetPath === runtimeHooksDir)
setRuntimeHooksDir(runtimeHooksDir)
const result = await getAllHooksDirs()
result.should.containEql(runtimeHooksDir)
})
it("should not include the runtime hooks directory when it does not exist", async () => {
const runtimeHooksDir = path.join(tempDir, "missing-runtime-hooks")
sandbox.stub(os, "homedir").returns(tempDir)
sandbox.stub(StateManager, "get").returns({
getGlobalStateKey: () => [],
} as any)
isDirectoryStub.resolves(false)
setRuntimeHooksDir(runtimeHooksDir)
const result = await getAllHooksDirs()
result.should.not.containEql(runtimeHooksDir)
})
})
})
describe("disk - atomic writes", () => {
let sandbox: sinon.SinonSandbox
let testGlobalStorageDir: string
// Setup HostProvider for tests with real temp directory
beforeAll(async () => {
// Create a real temp directory for the tests
testGlobalStorageDir = path.join(os.tmpdir(), `cline-test-storage-${Date.now()}-${Math.random().toString(36).slice(2)}`)
await fs.mkdir(testGlobalStorageDir, { recursive: true })
// Initialize HostProvider with the real temp directory
setVscodeHostProviderMock({
globalStorageFsPath: testGlobalStorageDir,
})
})
afterAll(async () => {
HostProvider.reset()
// Clean up temp directory
try {
await fs.rm(testGlobalStorageDir, { recursive: true, force: true })
} catch {
// Ignore cleanup errors
}
})
beforeEach(async () => {
sandbox = sinon.createSandbox()
})
afterEach(async () => {
sandbox.restore()
})
})
+1 -77
View File
@@ -2,14 +2,13 @@ import { Anthropic } from "@anthropic-ai/sdk"
import { TaskMetadata } from "@core/context/context-tracking/ContextTrackerTypes"
import { RemoteConfig } from "@shared/remote-config/schema"
import { GlobalState, Settings } from "@shared/storage/state-keys"
import { fileExistsAtPath, isDirectory } from "@utils/fs"
import { fileExistsAtPath } from "@utils/fs"
import fs from "fs/promises"
import os from "os"
import * as path from "path"
import { HostProvider } from "@/hosts/host-provider"
import { Logger } from "@/shared/services/Logger"
import { getDocumentsPath } from "./documents-path"
import { StateManager } from "./StateManager"
export { getDocumentsPath } from "./documents-path"
@@ -28,7 +27,6 @@ export const GlobalFileNames = {
mcpSettings: "cline_mcp_settings.json",
clineRules: ".clinerules",
workflows: ".clinerules/workflows",
hooksDir: ".clinerules/hooks",
clineruleSkillsDir: ".clinerules/skills",
clineSkillsDir: ".cline/skills",
claudeSkillsDir: ".claude/skills",
@@ -271,77 +269,3 @@ export async function deleteRemoteConfigFromCache(organizationId: string): Promi
Logger.error("Failed to delete remote config from cache:", error)
}
}
/**
* Gets the path to the global hooks directory if it exists.
* Returns undefined if the directory doesn't exist.
*/
async function getGlobalHooksDir(): Promise<string | undefined> {
const globalHooksDir = await ensureHooksDirectoryExists()
return (await isDirectory(globalHooksDir)) ? globalHooksDir : undefined
}
let runtimeHooksDir: string | undefined
/**
* Sets a runtime hooks directory, typically passed via the --hooks-dir CLI flag.
* This directory is included alongside global and workspace hooks directories
* when discovering hooks.
*/
export function setRuntimeHooksDir(dir: string | undefined): void {
runtimeHooksDir = dir
}
/**
* Gets the paths to all hooks directories to search for hooks, including:
* 1. The runtime hooks directory (if set via --hooks-dir CLI flag)
* 2. The global hooks directory (if it exists)
* 3. Each workspace root's .clinerules/hooks directory (if they exist)
*
* Note: Hooks from different directories may be executed concurrently.
* No execution order is guaranteed between hooks from different directories.
* A workspace may not use hooks, and the resulting array will be empty. A
* multi-root workspace may have multiple hooks directories.
*/
export async function getAllHooksDirs(): Promise<string[]> {
const hooksDirs: string[] = []
// Add runtime hooks directory (set by --hooks-dir CLI flag)
if (runtimeHooksDir && (await isDirectory(runtimeHooksDir))) {
hooksDirs.push(runtimeHooksDir)
}
// Add global hooks directory (if it exists)
const globalHooksDir = await getGlobalHooksDir()
if (globalHooksDir) {
hooksDirs.push(globalHooksDir)
}
// Add workspace hooks directories
const workspaceHooksDirs = await getWorkspaceHooksDirs()
hooksDirs.push(...workspaceHooksDirs)
return hooksDirs
}
/**
* Gets the paths to the workspace's .clinerules/hooks directories to search for
* hooks. A workspace may not use hooks, and the resulting array will be empty. A
* multi-root workspace may have multiple hooks directories.
*/
export async function getWorkspaceHooksDirs(): Promise<string[]> {
const workspaceRootPaths =
StateManager.get()
.getGlobalStateKey("workspaceRoots")
?.map((root) => root.path) || []
return (
await Promise.all(
workspaceRootPaths.map(async (workspaceRootPath) => {
// Look for a .clinerules/hooks folder in this workspace root.
const candidate = path.join(workspaceRootPath, GlobalFileNames.hooksDir)
return (await isDirectory(candidate)) ? candidate : undefined
}),
)
).filter((path): path is string => Boolean(path))
}
-30
View File
@@ -28,7 +28,6 @@ import { fixWithCline } from "./core/controller/commands/fixWithCline"
import { improveWithCline } from "./core/controller/commands/improveWithCline"
import { sendAddToInputEvent } from "./core/controller/ui/subscribeToAddToInput"
import { sendShowWebviewEvent } from "./core/controller/ui/subscribeToShowWebview"
import { HookDiscoveryCache } from "./core/hooks/HookDiscoveryCache"
import {
cleanupMcpMarketplaceCatalogFromGlobalState,
cleanupOldApiKey,
@@ -82,35 +81,6 @@ export async function activate(context: vscode.ExtensionContext) {
const webview = (await initialize(storageContext)) as VscodeWebviewProvider
// 5. Register services and commands specific to VS Code
// Initialize hook discovery cache for performance optimization
HookDiscoveryCache.getInstance().initialize(
// biome-ignore lint/suspicious/noExplicitAny: Adapt VSCode ExtensionContext to generic interface
context as any,
(dir: string) => {
try {
const pattern = new vscode.RelativePattern(dir, "*")
const watcher = vscode.workspace.createFileSystemWatcher(pattern)
// Ensure watcher is disposed when extension is deactivated
context.subscriptions.push(watcher)
// Adapt VSCode FileSystemWatcher to generic interface
return {
onDidCreate: (listener: () => void) => watcher.onDidCreate(listener),
onDidChange: (listener: () => void) => watcher.onDidChange(listener),
onDidDelete: (listener: () => void) => watcher.onDidDelete(listener),
dispose: () => watcher.dispose(),
}
} catch {
return null
}
},
(callback: () => void) => {
// Adapt VSCode Disposable to generic interface
const disposable = vscode.workspace.onDidChangeWorkspaceFolders(callback)
context.subscriptions.push(disposable)
return disposable
},
)
context.subscriptions.push(
vscode.window.registerWebviewViewProvider(VscodeWebviewProvider.SIDEBAR_ID, webview, {
webviewOptions: { retainContextWhenHidden: true },
-1
View File
@@ -257,7 +257,6 @@ export class Controller {
this.sessionHistory = new SdkSessionHistoryLoader()
this.sessionConfigBuilder = new SdkSessionConfigBuilder({
stateManager: this.stateManager,
emitHookMessage: (msg) => this.messages.emitHookMessage(msg),
onSwitchToActMode: () => {
this.mode.queueSwitchToActMode()
},
@@ -32,7 +32,6 @@ import { getDistinctId } from "@/services/logging/distinctId"
import { fetch } from "@/shared/net"
import { FeatureFlag } from "@/shared/services/feature-flags/feature-flags"
import { type BedrockProviderConfig, buildBedrockProviderConfig } from "./bedrock-config"
import { buildAgentHooks } from "./hooks-adapter"
import { readTaskHistory, resolveDataDir } from "./legacy-state-reader"
import { toSdkProviderId } from "./model-catalog/sdk-provider-id"
import { getProviderSettingsManager } from "./provider-migration"
@@ -719,7 +718,6 @@ export async function buildSessionConfig(input: SessionConfigInput): Promise<Cor
},
logger: sdkLogger,
},
hooks: buildAgentHooks(StateManager.get()),
}
return config
-360
View File
@@ -1,360 +0,0 @@
// Bridges Cline's file-based hook scripts into the SDK's runtime hooks.
//
// Runtime hooks use typed in-process lifecycle callbacks:
// TaskStart -> beforeRun
// UserPromptSubmit -> beforeRun with the latest submitted user message
// PreToolUse -> beforeTool
// PostToolUse -> afterTool
// TaskComplete -> afterRun when completed
// TaskCancel -> afterRun when aborted
//
// Deferred hooks (NOT wired here): TaskResume, TaskError, SessionShutdown,
// PreCompact, Notification.
import type {
AgentAfterToolContext,
AgentBeforeToolContext,
AgentHooks,
AgentRunLifecycleContext,
AgentStopControl,
} from "@cline/shared"
import type { ClineMessage } from "@shared/ExtensionMessage"
import { Logger } from "@shared/services/Logger"
import { HookFactory } from "@/core/hooks/hook-factory"
import { getHooksEnabledSafe } from "@/core/hooks/hooks-utils"
import type { StateManager } from "@/core/storage/StateManager"
export type HookMessageEmitter = (message: ClineMessage) => void
function toStringRecord(input: unknown): Record<string, string> {
if (input == null || typeof input !== "object" || Array.isArray(input)) {
return {}
}
const result: Record<string, string> = {}
for (const [key, value] of Object.entries(input as Record<string, unknown>)) {
result[key] = typeof value === "string" ? value : JSON.stringify(value)
}
return result
}
function mapStopControl(hookOutput: { cancel?: boolean; errorMessage?: string }): AgentStopControl | undefined {
if (!hookOutput.cancel) {
return undefined
}
return {
stop: true,
reason: hookOutput.errorMessage || undefined,
}
}
function taskIdFromSnapshot(snapshot: AgentRunLifecycleContext["snapshot"]): string {
return snapshot.conversationId ?? snapshot.runId ?? snapshot.agentId
}
function textFromMessageContent(content: readonly { type: string; text?: string }[]): string {
return content
.filter((part) => part.type === "text" && typeof part.text === "string")
.map((part) => part.text)
.join("")
}
function latestUserPrompt(ctx: AgentRunLifecycleContext): string {
for (let index = ctx.snapshot.messages.length - 1; index >= 0; index -= 1) {
const message = ctx.snapshot.messages[index]
if (message?.role === "user") {
return textFromMessageContent(message.content)
}
}
return ""
}
function buildHookStatusMessage(opts: {
hookName: string
status: "running" | "completed" | "failed" | "cancelled"
toolName?: string
ts?: number
}): ClineMessage {
return {
ts: opts.ts ?? Date.now(),
type: "say",
say: "hook_status",
text: JSON.stringify({
hookName: opts.hookName,
...(opts.toolName && { toolName: opts.toolName }),
status: opts.status,
}),
partial: false,
}
}
export function buildAgentHooks(stateManager: StateManager, emitHookMessage?: HookMessageEmitter): AgentHooks {
const hooksEnabled = () => getHooksEnabledSafe(stateManager.getGlobalSettingsKey("hooksEnabled"))
return {
async beforeRun(ctx: AgentRunLifecycleContext): Promise<AgentStopControl | undefined> {
const taskStartControl = await runTaskStart(ctx, hooksEnabled, emitHookMessage)
if (taskStartControl) {
return taskStartControl
}
return runUserPromptSubmit(ctx, hooksEnabled, emitHookMessage)
},
async beforeTool(ctx: AgentBeforeToolContext): Promise<{ stop?: boolean; reason?: string } | undefined> {
let runningTs: number | undefined
try {
if (!hooksEnabled()) {
return undefined
}
const factory = new HookFactory()
if (!(await factory.hasHook("PreToolUse"))) {
return undefined
}
const toolName = ctx.toolCall.toolName
const runningMsg = buildHookStatusMessage({ hookName: "PreToolUse", toolName, status: "running" })
runningTs = runningMsg.ts
emitHookMessage?.(runningMsg)
const runner = await factory.create("PreToolUse")
const result = await runner.run({
taskId: taskIdFromSnapshot(ctx.snapshot),
preToolUse: {
toolName,
parameters: toStringRecord(ctx.input),
},
})
emitHookMessage?.(
buildHookStatusMessage({
hookName: "PreToolUse",
toolName,
status: result.cancel ? "cancelled" : "completed",
ts: runningTs,
}),
)
return mapStopControl(result)
} catch (error) {
emitHookMessage?.(
buildHookStatusMessage({
hookName: "PreToolUse",
toolName: ctx.toolCall.toolName,
status: "failed",
ts: runningTs,
}),
)
Logger.error("[HooksAdapter] beforeTool hook failed:", error)
return undefined
}
},
async afterTool(ctx: AgentAfterToolContext): Promise<undefined> {
let runningTs: number | undefined
try {
if (!hooksEnabled()) {
return undefined
}
const factory = new HookFactory()
if (!(await factory.hasHook("PostToolUse"))) {
return undefined
}
const toolName = ctx.toolCall.toolName
const runningMsg = buildHookStatusMessage({ hookName: "PostToolUse", toolName, status: "running" })
runningTs = runningMsg.ts
emitHookMessage?.(runningMsg)
const runner = await factory.create("PostToolUse")
const result = await runner.run({
taskId: taskIdFromSnapshot(ctx.snapshot),
postToolUse: {
toolName,
parameters: toStringRecord(ctx.input),
result: String(ctx.result.output ?? ""),
success: !ctx.result.isError,
executionTimeMs: ctx.durationMs,
},
})
emitHookMessage?.(
buildHookStatusMessage({
hookName: "PostToolUse",
toolName,
status: result.cancel ? "cancelled" : "completed",
ts: runningTs,
}),
)
return undefined
} catch (error) {
emitHookMessage?.(
buildHookStatusMessage({
hookName: "PostToolUse",
toolName: ctx.toolCall.toolName,
status: "failed",
ts: runningTs,
}),
)
Logger.error("[HooksAdapter] afterTool hook failed:", error)
return undefined
}
},
async afterRun(ctx): Promise<void> {
let hookName: "TaskComplete" | "TaskCancel" | undefined
let runningTs: number | undefined
try {
if (!hooksEnabled()) {
return
}
hookName =
ctx.result.status === "completed"
? "TaskComplete"
: ctx.result.status === "aborted"
? "TaskCancel"
: undefined
if (!hookName) {
return
}
const factory = new HookFactory()
if (!(await factory.hasHook(hookName))) {
return
}
const taskId = taskIdFromSnapshot(ctx.snapshot)
const runningMsg = buildHookStatusMessage({ hookName, status: "running" })
runningTs = runningMsg.ts
emitHookMessage?.(runningMsg)
if (hookName === "TaskComplete") {
const runner = await factory.create("TaskComplete")
await runner.run({
taskId,
taskComplete: {
taskMetadata: {
taskId,
ulid: "",
initialTask: "",
result: ctx.result.outputText,
},
},
})
} else {
const runner = await factory.create("TaskCancel")
await runner.run({
taskId,
taskCancel: {
taskMetadata: {
taskId,
ulid: "",
initialTask: "",
completionStatus: "cancelled",
},
},
})
}
emitHookMessage?.(buildHookStatusMessage({ hookName, status: "completed", ts: runningTs }))
} catch (error) {
emitHookMessage?.(
buildHookStatusMessage({ hookName: hookName ?? "TaskComplete", status: "failed", ts: runningTs }),
)
Logger.error("[HooksAdapter] afterRun hook failed:", error)
}
},
}
}
async function runTaskStart(
ctx: AgentRunLifecycleContext,
hooksEnabled: () => boolean,
emitHookMessage?: HookMessageEmitter,
): Promise<AgentStopControl | undefined> {
let runningTs: number | undefined
try {
if (!hooksEnabled()) {
return undefined
}
const factory = new HookFactory()
if (!(await factory.hasHook("TaskStart"))) {
return undefined
}
const runningMsg = buildHookStatusMessage({ hookName: "TaskStart", status: "running" })
runningTs = runningMsg.ts
emitHookMessage?.(runningMsg)
const taskId = taskIdFromSnapshot(ctx.snapshot)
const runner = await factory.create("TaskStart")
const result = await runner.run({
taskId,
taskStart: {
taskMetadata: {
taskId,
ulid: "",
initialTask: latestUserPrompt(ctx),
},
},
})
emitHookMessage?.(
buildHookStatusMessage({
hookName: "TaskStart",
status: result.cancel ? "cancelled" : "completed",
ts: runningTs,
}),
)
return mapStopControl(result)
} catch (error) {
emitHookMessage?.(buildHookStatusMessage({ hookName: "TaskStart", status: "failed", ts: runningTs }))
Logger.error("[HooksAdapter] beforeRun (TaskStart) hook failed:", error)
return undefined
}
}
async function runUserPromptSubmit(
ctx: AgentRunLifecycleContext,
hooksEnabled: () => boolean,
emitHookMessage?: HookMessageEmitter,
): Promise<AgentStopControl | undefined> {
let runningTs: number | undefined
try {
if (!hooksEnabled()) {
return undefined
}
const factory = new HookFactory()
if (!(await factory.hasHook("UserPromptSubmit"))) {
return undefined
}
const runningMsg = buildHookStatusMessage({ hookName: "UserPromptSubmit", status: "running" })
runningTs = runningMsg.ts
emitHookMessage?.(runningMsg)
const runner = await factory.create("UserPromptSubmit")
const result = await runner.run({
taskId: taskIdFromSnapshot(ctx.snapshot),
userPromptSubmit: {
prompt: latestUserPrompt(ctx),
attachments: [],
},
})
emitHookMessage?.(
buildHookStatusMessage({
hookName: "UserPromptSubmit",
status: result.cancel ? "cancelled" : "completed",
ts: runningTs,
}),
)
return mapStopControl(result)
} catch (error) {
emitHookMessage?.(buildHookStatusMessage({ hookName: "UserPromptSubmit", status: "failed", ts: runningTs }))
Logger.error("[HooksAdapter] beforeRun (UserPromptSubmit) hook failed:", error)
return undefined
}
}
+10 -2
View File
@@ -1275,7 +1275,11 @@ describe("translateSessionEvent — hook events", () => {
const result = translateSessionEvent(event, state)
expect(result.messages).toHaveLength(1)
expect(result.messages[0].say).toBe("hook_status")
expect(result.messages[0].text).toContain("write_to_file")
expect(JSON.parse(result.messages[0].text!)).toEqual({
hookName: "tool_call",
toolName: "write_to_file",
status: "completed",
})
})
it("translates tool_result hook to hook_status message", () => {
@@ -1291,7 +1295,11 @@ describe("translateSessionEvent — hook events", () => {
const result = translateSessionEvent(event, state)
expect(result.messages).toHaveLength(1)
expect(result.messages[0].text).toContain("completed")
expect(JSON.parse(result.messages[0].text!)).toEqual({
hookName: "tool_result",
toolName: "read_files",
status: "completed",
})
})
})
+11 -18
View File
@@ -1566,28 +1566,21 @@ export function translateSessionEvent(event: CoreSessionEvent, state: MessageTra
break
}
// Tool hook events — translate to hook_status messages
const payload = event.payload
const hookName = payload.hookEventName
const toolName = payload.toolName
if (hookName === "tool_call") {
result.messages.push({
ts: state.nextTs(),
type: "say",
say: "hook_status" as ClineSay,
text: toolName ? `Running ${toolName}...` : "Running tool...",
partial: false,
})
} else if (hookName === "tool_result") {
result.messages.push({
ts: state.nextTs(),
type: "say",
say: "hook_status" as ClineSay,
text: toolName ? `${toolName} completed` : "Tool completed",
partial: false,
})
}
result.messages.push({
ts: state.nextTs(),
type: "say",
say: "hook_status" as ClineSay,
text: JSON.stringify({
hookName,
...(toolName && { toolName }),
status: hookName === "agent_error" ? "failed" : "completed",
}),
partial: false,
})
break
}
@@ -3,7 +3,6 @@ import type { ClineApiReqInfo, ClineMessage } from "@shared/ExtensionMessage"
import { Logger } from "@/shared/services/Logger"
import type { MessageIdMinter } from "./message-id-minter"
import type { TaskProxy } from "./task-proxy"
import { pushMessageToWebview } from "./webview-grpc-bridge"
export type SessionEventListener = (messages: ClineMessage[], event: CoreSessionEvent) => void
@@ -100,11 +99,6 @@ export class SdkMessageCoordinator {
this.emitSessionEvents(messages, event)
}
emitHookMessage(message: ClineMessage): void {
this.appendMessages([message])
pushMessageToWebview(message).catch(() => {})
}
/**
* Finalize messages for saving to disk when a task is being cleared.
* - Strips `partial` flags so the UI doesn't show a streaming/cancel state
@@ -3,17 +3,12 @@ import { SdkSessionConfigBuilder } from "./sdk-session-config-builder"
const mocks = vi.hoisted(() => ({
buildSessionConfig: vi.fn(),
buildAgentHooks: vi.fn(() => ({})),
}))
vi.mock("./cline-session-factory", () => ({
buildSessionConfig: mocks.buildSessionConfig,
}))
vi.mock("./hooks-adapter", () => ({
buildAgentHooks: mocks.buildAgentHooks,
}))
describe("SdkSessionConfigBuilder", () => {
it("adds the CLI plan-mode switch_to_act_mode tool only in plan mode", async () => {
const stateManager = {
@@ -22,7 +17,6 @@ describe("SdkSessionConfigBuilder", () => {
const onSwitchToActMode = vi.fn()
const builder = new SdkSessionConfigBuilder({
stateManager: stateManager as never,
emitHookMessage: vi.fn(),
onSwitchToActMode,
})
@@ -52,12 +46,10 @@ describe("SdkSessionConfigBuilder", () => {
it("stops before the next model call after switch_to_act_mode queues a mode change", async () => {
const baseBeforeModel = vi.fn(async () => ({ metadata: "base" }))
mocks.buildAgentHooks.mockReturnValueOnce({ beforeModel: baseBeforeModel })
mocks.buildSessionConfig.mockResolvedValueOnce({ hooks: {} })
mocks.buildSessionConfig.mockResolvedValueOnce({ hooks: { beforeModel: baseBeforeModel } })
const builder = new SdkSessionConfigBuilder({
stateManager: {} as never,
emitHookMessage: vi.fn(),
onSwitchToActMode: vi.fn(),
shouldStopAfterModeSwitch: () => true,
})
@@ -77,7 +69,6 @@ describe("SdkSessionConfigBuilder", () => {
const builder = new SdkSessionConfigBuilder({
stateManager: { getGlobalSettingsKey: vi.fn(() => 3) } as never,
emitHookMessage: vi.fn(),
onSwitchToActMode: vi.fn(),
onConsecutiveMistakeLimitReached,
})
@@ -2,11 +2,9 @@ import type { CoreSessionConfig } from "@cline/core"
import { type AgentTool, createTool } from "@cline/shared"
import type { StateManager } from "@/core/storage/StateManager"
import { buildSessionConfig, type SessionConfigInput } from "./cline-session-factory"
import { buildAgentHooks, type HookMessageEmitter } from "./hooks-adapter"
export interface SdkSessionConfigBuilderOptions {
stateManager: StateManager
emitHookMessage: HookMessageEmitter
onSwitchToActMode: () => void
shouldStopAfterModeSwitch?: () => boolean
onConsecutiveMistakeLimitReached?: CoreSessionConfig["onConsecutiveMistakeLimitReached"]
@@ -21,19 +19,21 @@ export class SdkSessionConfigBuilder {
config.onConsecutiveMistakeLimitReached = this.options.onConsecutiveMistakeLimitReached
}
const baseHooks = buildAgentHooks(this.options.stateManager, this.options.emitHookMessage)
config.hooks = {
...baseHooks,
beforeModel: async (ctx) => {
const baseControl = await baseHooks.beforeModel?.(ctx)
if (this.options.shouldStopAfterModeSwitch?.()) {
return {
...baseControl,
stop: true,
if (this.options.shouldStopAfterModeSwitch) {
const existingHooks = config.hooks
config.hooks = {
...existingHooks,
beforeModel: async (ctx) => {
const baseControl = await existingHooks?.beforeModel?.(ctx)
if (this.options.shouldStopAfterModeSwitch?.()) {
return {
...baseControl,
stop: true,
}
}
}
return baseControl
},
return baseControl
},
}
}
if (input.mode === "plan") {
// Match the CLI interactive runtime: plan-mode sessions expose a
@@ -2161,7 +2161,7 @@ export class TelemetryService {
* This is the simplified API that consolidates multiple hook execution methods.
*
* @param ulid Task identifier
* @param hookName Type of hook (PreToolUse, PostToolUse, etc.)
* @param hookName Type of hook, such as tool_call or tool_result
* @param status Current execution status
* @param metadata Optional execution metadata
*/
@@ -2546,12 +2546,12 @@ export class TelemetryService {
* property access, calculations) and provider-level errors (network, API failures).
*
* @param telemetryFn The telemetry function to execute
* @param context Optional context string for debugging (e.g., "HookFactory.exec")
* @param context Optional context string for debugging (e.g., "hook.execution")
*
* @example
* telemetryService.safeCapture(
* () => telemetryService.captureHookExecution(taskId, hookName, "started", {...}),
* 'HookFactory.exec.started'
* 'hook.execution.started'
* )
*/
public safeCapture(telemetryFn: () => void, context?: string): void {
@@ -122,7 +122,6 @@ export interface ExtensionState {
lastDismissedModelBannerVersion: number
lastDismissedCliBannerVersion: number
dismissedBanners?: Array<{ bannerId: string; dismissedAt: number }>
hooksEnabled?: boolean
remoteConfigSettings?: Partial<RemoteConfigFields>
globalSkillsToggles?: Record<string, boolean>
localSkillsToggles?: Record<string, boolean>
+35 -31
View File
@@ -58,6 +58,10 @@ function parseHookMetadata(hookMessage: ClineMessage): HookMetadata | null {
}
}
function isPreToolHook(metadata: HookMetadata | null): boolean {
return metadata?.hookName === "PreToolUse" || metadata?.hookName === "tool_call"
}
// ============================================================================
// PART 2: FILTERING & COMBINING
// ============================================================================
@@ -166,7 +170,7 @@ function combineAllHooks(messages: ClineMessage[]): ClineMessage[] {
}
// ============================================================================
// PART 3: PRETOOLUSE REORDERING
// PART 3: PRE-TOOL HOOK REORDERING
// ============================================================================
/**
@@ -189,11 +193,11 @@ function findImmediateNextToolTimestamp(hookIndex: number, messages: ClineMessag
return msg.ts
}
// If we hit another PreToolUse hook before finding a tool, stop searching
// This prevents matching a hook to a tool that has its own PreToolUse hook
// If we hit another pre-tool hook before finding a tool, stop searching.
// This prevents matching a hook to a tool that has its own pre-tool hook.
if (isHookStatusSay(getSay(msg))) {
const metadata = parseHookMetadata(msg)
if (metadata?.hookName === "PreToolUse") {
if (isPreToolHook(metadata)) {
return null
}
}
@@ -202,21 +206,21 @@ function findImmediateNextToolTimestamp(hookIndex: number, messages: ClineMessag
}
/**
* Builds a map of tool timestamps to their PreToolUse hooks.
* Builds a map of tool timestamps to their pre-tool hooks.
*
* This map indicates which hooks should be moved to appear before which tools.
* Only PreToolUse hooks are included; PostToolUse hooks stay in their original position.
* Only pre-tool hooks are included; post-tool hooks stay in their original position.
*
* A PreToolUse hook should only be mapped to a tool if the hook was created AFTER the
* A pre-tool hook should only be mapped to a tool if the hook was created AFTER the
* tool already exists in the message stream. This can happen when hooks arrive late
* or out of order. If the hook timestamp < tool timestamp, it means the hook
* naturally appears before the tool chronologically and should NOT be moved.
*
* @param processedMessages Messages after filtering and combining
* @param originalMessages Original messages array (used to find tools)
* @returns Map of tool timestamp -> array of PreToolUse hooks for that tool
* @returns Map of tool timestamp -> array of pre-tool hooks for that tool
*/
function buildPreToolUseMap(processedMessages: ClineMessage[], originalMessages: ClineMessage[]): Map<number, ClineMessage[]> {
function buildPreToolHookMap(processedMessages: ClineMessage[], originalMessages: ClineMessage[]): Map<number, ClineMessage[]> {
const map = new Map<number, ClineMessage[]>()
// Build timestamp-to-index map once to avoid O(n) findIndex calls
@@ -226,9 +230,9 @@ function buildPreToolUseMap(processedMessages: ClineMessage[], originalMessages:
}
for (const msg of processedMessages) {
// Only process PreToolUse hooks
// Only process pre-tool hooks.
const metadata = parseHookMetadata(msg)
if (metadata?.hookName !== "PreToolUse") {
if (!isPreToolHook(metadata)) {
continue
}
@@ -263,21 +267,21 @@ function buildPreToolUseMap(processedMessages: ClineMessage[], originalMessages:
}
/**
* Reorders messages so PreToolUse hooks appear before their associated tools.
* Reorders messages so pre-tool hooks appear before their associated tools.
*
* Algorithm:
* 1. When we encounter a tool, check if it has PreToolUse hooks mapped to it
* 1. When we encounter a tool, check if it has pre-tool hooks mapped to it
* 2. If yes, insert those hooks BEFORE the tool
* 3. Track which hooks and tools we've already added to avoid duplicates
* 4. For PreToolUse hooks encountered in their original position:
* 4. For pre-tool hooks encountered in their original position:
* - If their tool is available and we'll process them before it, skip them
* - Otherwise, add them in their current position (tool not available yet)
*
* @param messages Messages after filtering and combining
* @param preToolUseMap Map of tool timestamp -> PreToolUse hooks
* @param preToolHookMap Map of tool timestamp -> pre-tool hooks
* @returns Reordered messages array
*/
function reorderWithPreToolUseHooks(messages: ClineMessage[], preToolUseMap: Map<number, ClineMessage[]>): ClineMessage[] {
function reorderWithPreToolHooks(messages: ClineMessage[], preToolHookMap: Map<number, ClineMessage[]>): ClineMessage[] {
const result: ClineMessage[] = []
const addedHooks = new Set<number>()
const addedTools = new Set<number>()
@@ -291,9 +295,9 @@ function reorderWithPreToolUseHooks(messages: ClineMessage[], preToolUseMap: Map
}
for (const msg of messages) {
// Case 1: This is a tool with PreToolUse hooks
if (isToolOrCommandMessage(msg) && preToolUseMap.has(msg.ts)) {
const hooksForTool = preToolUseMap.get(msg.ts)!
// Case 1: This is a tool with pre-tool hooks
if (isToolOrCommandMessage(msg) && preToolHookMap.has(msg.ts)) {
const hooksForTool = preToolHookMap.get(msg.ts)!
// Insert hooks that haven't been added yet
const newHooks = hooksForTool.filter((h) => !addedHooks.has(h.ts))
@@ -311,12 +315,12 @@ function reorderWithPreToolUseHooks(messages: ClineMessage[], preToolUseMap: Map
continue
}
// Case 3: This is a PreToolUse hook in its original position
// Case 3: This is a pre-tool hook in its original position
const metadata = parseHookMetadata(msg)
if (metadata?.hookName === "PreToolUse") {
if (isPreToolHook(metadata)) {
// Find which tool (if any) this hook is mapped to
let mappedToolTs: number | undefined
for (const [toolTs, hooks] of preToolUseMap) {
for (const [toolTs, hooks] of preToolHookMap) {
if (hooks.some((h) => h.ts === msg.ts)) {
mappedToolTs = toolTs
break
@@ -331,7 +335,7 @@ function reorderWithPreToolUseHooks(messages: ClineMessage[], preToolUseMap: Map
// Otherwise, keep hook in original position (tool not available yet)
}
// Case 4: All other messages (text, PostToolUse hooks, reasoning, etc.)
// Case 4: All other messages (text, post-tool hooks, reasoning, etc.)
result.push(msg)
}
@@ -344,16 +348,16 @@ function reorderWithPreToolUseHooks(messages: ClineMessage[], preToolUseMap: Map
/**
* Combines sequences of hook and hook_output messages, and reorders
* PreToolUse hooks to appear before their associated tool messages.
* pre-tool hooks to appear before their associated tool messages.
*
* Process:
* 1. Deduplicate tool/command messages by timestamp (preserve newest variant)
* 2. Combine hooks with their hook_output messages
* 3. Build mapping of tools to their PreToolUse hooks
* 4. Reorder so PreToolUse hooks appear before their tools
* 3. Build mapping of tools to their pre-tool hooks
* 4. Reorder so pre-tool hooks appear before their tools
*
* @param messages Array of ClineMessage objects to process
* @returns New array with hooks combined and PreToolUse hooks reordered
* @returns New array with hooks combined and pre-tool hooks reordered
*/
export function combineHookSequences(messages: ClineMessage[]): ClineMessage[] {
// Phase 1: Deduplicate tool/command messages while preserving streaming partials
@@ -362,11 +366,11 @@ export function combineHookSequences(messages: ClineMessage[]): ClineMessage[] {
// Phase 2: Combine hooks with their outputs
const combined = combineAllHooks(filtered)
// Phase 3: Build PreToolUse hook mapping
const preToolUseMap = buildPreToolUseMap(combined, messages)
// Phase 3: Build pre-tool hook mapping
const preToolHookMap = buildPreToolHookMap(combined, messages)
// Phase 4: Reorder to place PreToolUse hooks before tools
const reordered = reorderWithPreToolUseHooks(combined, preToolUseMap)
// Phase 4: Reorder to place pre-tool hooks before tools
const reordered = reorderWithPreToolHooks(combined, preToolHookMap)
return reordered
}
@@ -260,7 +260,6 @@ const USER_SETTINGS_FIELDS = {
shellIntegrationTimeout: { default: 4000 as number },
defaultTerminalProfile: { default: "default" as string },
maxConsecutiveMistakes: { default: 3 as number },
hooksEnabled: { default: true as boolean },
yoloModeToggled: { default: false as boolean },
autoApproveAllToggled: { default: false as boolean },
useAutoCondense: { default: false as boolean },
@@ -1,424 +0,0 @@
import { afterEach, beforeEach, describe, it } from "bun:test"
import "should"
import * as fs from "fs/promises"
import * as os from "os"
import * as path from "path"
import * as sinon from "sinon"
import { Controller } from "../core/controller"
import { createHook } from "../core/controller/file/createHook"
import { deleteHook } from "../core/controller/file/deleteHook"
import { refreshHooks } from "../core/controller/file/refreshHooks"
import { toggleHook } from "../core/controller/file/toggleHook"
import { hookFileName } from "../core/hooks/__tests__/test-utils"
import { HookDiscoveryCache } from "../core/hooks/HookDiscoveryCache"
import { StateManager } from "../core/storage/StateManager"
import { HostProvider } from "../hosts/host-provider"
import { CreateHookRequest, DeleteHookRequest, ToggleHookRequest } from "../shared/proto/cline/file"
/**
* Integration tests for hook management
* Tests the complete lifecycle: create -> enable -> disable -> delete
*/
describe("Hook Management Integration", () => {
const isWindows = process.platform === "win32"
let tempDir: string
let globalHooksDir: string
let workspaceHooksDir: string
let mockController: Controller
let stateManagerStub: sinon.SinonStub
let getWorkspacePathsStub: sinon.SinonStub
beforeEach(async () => {
// Reset the hook discovery cache before each test
HookDiscoveryCache.resetForTesting()
// Create temporary directories
tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "hook-integration-test-"))
globalHooksDir = path.join(tempDir, "global", "Documents", "Cline", "Hooks")
workspaceHooksDir = path.join(tempDir, "workspace", ".clinerules", "hooks")
await fs.mkdir(globalHooksDir, { recursive: true })
await fs.mkdir(workspaceHooksDir, { recursive: true })
// Mock Controller
mockController = {
context: {
globalStorageUri: { fsPath: path.join(tempDir, "global") },
},
} as any
// Mock StateManager to return test workspace
stateManagerStub = sinon.stub(StateManager, "get").returns({
getGlobalStateKey: (key: string) => {
if (key === "workspaceRoots") {
return [{ path: path.join(tempDir, "workspace") }]
}
return undefined
},
} as any)
// Mock HostProvider.workspace.getWorkspacePaths - need to stub the method directly
getWorkspacePathsStub = sinon.stub().resolves({
paths: [path.join(tempDir, "workspace")],
})
sinon.stub(HostProvider, "workspace").value({
getWorkspacePaths: getWorkspacePathsStub,
})
})
afterEach(async () => {
// Clean up temporary directory
try {
await fs.rm(tempDir, { recursive: true, force: true })
} catch (error) {
// Ignore cleanup errors
}
// Restore all stubs
sinon.restore()
})
describe("Complete Hook Lifecycle", () => {
it("should support full lifecycle: create -> verify disabled -> enable -> verify enabled -> delete -> verify gone", async () => {
const hookName = "TaskStart"
// Step 1: Verify hook doesn't exist initially
let hooks = await refreshHooks(mockController, undefined, globalHooksDir)
hooks.globalHooks.should.have.length(0)
// Step 2: Create the hook
const createRequest = CreateHookRequest.create({
hookName,
isGlobal: true,
})
const createResponse = await createHook(mockController, createRequest, globalHooksDir)
// Step 3: Verify hook was created and is disabled (644 permissions)
createResponse.hooksToggles!.globalHooks.should.have.length(1)
createResponse.hooksToggles!.globalHooks[0].name.should.equal(hookName)
if (isWindows) {
createResponse.hooksToggles!.globalHooks[0].enabled.should.equal(true)
} else {
createResponse.hooksToggles!.globalHooks[0].enabled.should.equal(false)
}
const hookPath = path.join(globalHooksDir, hookFileName(hookName))
if (!isWindows) {
const createStats = await fs.stat(hookPath)
const createMode = createStats.mode & 0o777
createMode.should.equal(0o644)
}
// Step 4: Enable the hook
const enableRequest = ToggleHookRequest.create({
hookName,
isGlobal: true,
enabled: true,
})
const enableResponse = await toggleHook(mockController, enableRequest, globalHooksDir)
// Step 5: Verify hook is now enabled (executable)
enableResponse.hooksToggles!.globalHooks.should.have.length(1)
enableResponse.hooksToggles!.globalHooks[0].enabled.should.equal(true)
const enableStats = await fs.stat(hookPath)
const enableMode = enableStats.mode & 0o777
if (!isWindows) {
;(enableMode & 0o100).should.be.greaterThan(0)
}
// Step 6: Disable the hook
const disableRequest = ToggleHookRequest.create({
hookName,
isGlobal: true,
enabled: false,
})
const disableResponse = await toggleHook(mockController, disableRequest, globalHooksDir)
// Step 7: Verify hook is now disabled again
disableResponse.hooksToggles!.globalHooks.should.have.length(1)
if (isWindows) {
// On Windows, toggling is chmod-noop and enabled reflects file existence.
disableResponse.hooksToggles!.globalHooks[0].enabled.should.equal(true)
} else {
disableResponse.hooksToggles!.globalHooks[0].enabled.should.equal(false)
const disableStats = await fs.stat(hookPath)
const disableMode = disableStats.mode & 0o777
disableMode.should.equal(0o644)
}
// Step 8: Delete the hook
const deleteRequest = DeleteHookRequest.create({
hookName,
isGlobal: true,
})
const deleteResponse = await deleteHook(mockController, deleteRequest, globalHooksDir)
// Step 9: Verify hook is gone
deleteResponse.hooksToggles!.globalHooks.should.have.length(0)
const hookExists = await fs
.access(hookPath)
.then(() => true)
.catch(() => false)
hookExists.should.equal(false)
// Step 10: Final refresh to confirm clean state
hooks = await refreshHooks(mockController, undefined, globalHooksDir)
hooks.globalHooks.should.have.length(0)
}, 10000)
it("should handle multiple global hooks with independent states", async () => {
// Create four global hooks
await createHook(
mockController,
CreateHookRequest.create({
hookName: "TaskStart",
isGlobal: true,
}),
globalHooksDir,
)
await createHook(
mockController,
CreateHookRequest.create({
hookName: "TaskResume",
isGlobal: true,
}),
globalHooksDir,
)
await createHook(
mockController,
CreateHookRequest.create({
hookName: "UserPromptSubmit",
isGlobal: true,
}),
globalHooksDir,
)
await createHook(
mockController,
CreateHookRequest.create({
hookName: "TaskComplete",
isGlobal: true,
}),
globalHooksDir,
)
// Verify all hooks are present and disabled
const hooks = await refreshHooks(mockController, undefined, globalHooksDir)
hooks.globalHooks.should.have.length(4)
hooks.globalHooks.forEach((hook) => {
if (isWindows) {
hook.enabled.should.equal(true)
} else {
hook.enabled.should.equal(false)
}
})
// Enable two of them
await toggleHook(
mockController,
ToggleHookRequest.create({
hookName: "TaskStart",
isGlobal: true,
enabled: true,
}),
globalHooksDir,
)
await toggleHook(
mockController,
ToggleHookRequest.create({
hookName: "UserPromptSubmit",
isGlobal: true,
enabled: true,
}),
globalHooksDir,
)
// Verify states are independent
const hooksAfterToggle = await refreshHooks(mockController, undefined, globalHooksDir)
const taskStart = hooksAfterToggle.globalHooks.find((h) => h.name === "TaskStart")
const taskResume = hooksAfterToggle.globalHooks.find((h) => h.name === "TaskResume")
const userPrompt = hooksAfterToggle.globalHooks.find((h) => h.name === "UserPromptSubmit")
const taskComplete = hooksAfterToggle.globalHooks.find((h) => h.name === "TaskComplete")
if (isWindows) {
taskStart!.enabled.should.equal(true)
taskResume!.enabled.should.equal(true)
userPrompt!.enabled.should.equal(true)
taskComplete!.enabled.should.equal(true)
} else {
taskStart!.enabled.should.equal(true)
taskResume!.enabled.should.equal(false)
userPrompt!.enabled.should.equal(true)
taskComplete!.enabled.should.equal(false)
}
// Clean up - delete all hooks
await deleteHook(
mockController,
DeleteHookRequest.create({
hookName: "TaskStart",
isGlobal: true,
}),
globalHooksDir,
)
await deleteHook(
mockController,
DeleteHookRequest.create({
hookName: "TaskResume",
isGlobal: true,
}),
globalHooksDir,
)
await deleteHook(
mockController,
DeleteHookRequest.create({
hookName: "UserPromptSubmit",
isGlobal: true,
}),
globalHooksDir,
)
await deleteHook(
mockController,
DeleteHookRequest.create({
hookName: "TaskComplete",
isGlobal: true,
}),
globalHooksDir,
)
// Verify all are gone
const finalHooks = await refreshHooks(mockController, undefined, globalHooksDir)
finalHooks.globalHooks.should.have.length(0)
}, 10000)
it("should maintain hook state consistency after rapid operations", async () => {
const hookName = "TaskCancel"
// Rapid sequence of operations
await createHook(
mockController,
CreateHookRequest.create({
hookName,
isGlobal: true,
}),
globalHooksDir,
)
// Toggle multiple times
await toggleHook(
mockController,
ToggleHookRequest.create({
hookName,
isGlobal: true,
enabled: true,
}),
globalHooksDir,
)
await toggleHook(
mockController,
ToggleHookRequest.create({
hookName,
isGlobal: true,
enabled: false,
}),
globalHooksDir,
)
await toggleHook(
mockController,
ToggleHookRequest.create({
hookName,
isGlobal: true,
enabled: true,
}),
globalHooksDir,
)
// Verify final state
const hooks = await refreshHooks(mockController, undefined, globalHooksDir)
hooks.globalHooks.should.have.length(1)
hooks.globalHooks[0].enabled.should.equal(true)
// Verify file permissions match
const hookPath = path.join(globalHooksDir, hookName)
if (!isWindows) {
const stats = await fs.stat(hookPath)
const mode = stats.mode & 0o777
;(mode & 0o100).should.be.greaterThan(0)
}
}, 10000)
})
describe("Cache Invalidation", () => {
it("should properly invalidate cache across all operations", async () => {
// Create a hook
await createHook(
mockController,
CreateHookRequest.create({
hookName: "TaskStart",
isGlobal: true,
}),
globalHooksDir,
)
// First refresh should find it
let hooks = await refreshHooks(mockController, undefined, globalHooksDir)
hooks.globalHooks.should.have.length(1)
// Modify file permissions directly (simulating external change)
const hookPath = path.join(globalHooksDir, isWindows ? "TaskStart.ps1" : "TaskStart")
if (!isWindows) {
await fs.chmod(hookPath, 0o755)
}
// Second refresh should see the permission change
// (This tests that refreshHooks properly reads current state)
hooks = await refreshHooks(mockController, undefined, globalHooksDir)
hooks.globalHooks[0].enabled.should.equal(true)
// Use toggle to change it back
await toggleHook(
mockController,
ToggleHookRequest.create({
hookName: "TaskStart",
isGlobal: true,
enabled: false,
}),
globalHooksDir,
)
// Third refresh should see the toggle
hooks = await refreshHooks(mockController, undefined, globalHooksDir)
if (isWindows) {
hooks.globalHooks[0].enabled.should.equal(true)
} else {
hooks.globalHooks[0].enabled.should.equal(false)
}
// Delete it
await deleteHook(
mockController,
DeleteHookRequest.create({
hookName: "TaskStart",
isGlobal: true,
}),
globalHooksDir,
)
// Final refresh should show it's gone
hooks = await refreshHooks(mockController, undefined, globalHooksDir)
hooks.globalHooks.should.have.length(0)
}, 10000)
})
})
@@ -1,580 +0,0 @@
import { afterEach, beforeEach, describe, it } from "bun:test"
import "should"
import * as fs from "fs/promises"
import * as os from "os"
import * as path from "path"
import * as sinon from "sinon"
import { Controller } from "../core/controller"
import { createHook } from "../core/controller/file/createHook"
import { deleteHook } from "../core/controller/file/deleteHook"
import { refreshHooks } from "../core/controller/file/refreshHooks"
import { toggleHook } from "../core/controller/file/toggleHook"
import { hookFileName, withPlatform } from "../core/hooks/__tests__/test-utils"
import { HookDiscoveryCache } from "../core/hooks/HookDiscoveryCache"
import { StateManager } from "../core/storage/StateManager"
import { HostProvider } from "../hosts/host-provider"
import { CreateHookRequest, DeleteHookRequest, ToggleHookRequest } from "../shared/proto/cline/file"
/**
* Unit tests for hook management operations
* Tests the create, delete, toggle, and refresh hook functionality
*/
describe("Hook Management", () => {
const isWindows = process.platform === "win32"
const hookTemplate = isWindows ? "Write-Output '{}'" : "#!/usr/bin/env node"
let tempDir: string
let globalHooksDir: string
let workspaceHooksDir: string
let mockController: Controller
let stateManagerStub: sinon.SinonStub
let getWorkspacePathsStub: sinon.SinonStub
beforeEach(async () => {
// Reset the hook discovery cache before each test
HookDiscoveryCache.resetForTesting()
// Create temporary directories
tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "hook-mgmt-test-"))
globalHooksDir = path.join(tempDir, "global", "Documents", "Cline", "Hooks")
workspaceHooksDir = path.join(tempDir, "workspace", ".clinerules", "hooks")
await fs.mkdir(globalHooksDir, { recursive: true })
await fs.mkdir(workspaceHooksDir, { recursive: true })
// Mock Controller
mockController = {
context: {
globalStorageUri: { fsPath: path.join(tempDir, "global") },
},
} as any
// Mock StateManager to return test workspace
stateManagerStub = sinon.stub(StateManager, "get").returns({
getGlobalStateKey: (key: string) => {
if (key === "workspaceRoots") {
return [{ path: path.join(tempDir, "workspace") }]
}
return undefined
},
} as any)
// Mock HostProvider.workspace.getWorkspacePaths - need to stub the method directly
getWorkspacePathsStub = sinon.stub().resolves({
paths: [path.join(tempDir, "workspace")],
})
sinon.stub(HostProvider, "workspace").value({
getWorkspacePaths: getWorkspacePathsStub,
})
})
afterEach(async () => {
// Clean up temporary directory
try {
await fs.rm(tempDir, { recursive: true, force: true })
} catch (error) {
// Ignore cleanup errors
}
// Restore all stubs
sinon.restore()
})
describe("createHook", () => {
it("should create hook with correct template content", async () => {
const request = CreateHookRequest.create({
hookName: "TaskStart",
isGlobal: true,
})
const response = await createHook(mockController, request, globalHooksDir)
// Verify file was created
const hookPath = path.join(globalHooksDir, hookFileName("TaskStart"))
const exists = await fs
.access(hookPath)
.then(() => true)
.catch(() => false)
exists.should.equal(true)
// Verify content contains expected template structure
const content = await fs.readFile(hookPath, "utf-8")
if (isWindows) {
content.should.containEql("PowerShell template for Windows hook execution")
} else {
content.should.containEql("#!/bin/bash")
}
content.should.containEql("TaskStart Hook")
// Verify response contains updated hooks state
response.should.have.property("hooksToggles")
response.hooksToggles!.globalHooks.should.have.length(1)
response.hooksToggles!.globalHooks[0].name.should.equal("TaskStart")
}, 5000)
it("should create hook with non-executable permissions (644)", async () => {
if (isWindows) {
return
}
const request = CreateHookRequest.create({
hookName: "TaskResume",
isGlobal: false,
})
await createHook(mockController, request)
const hookPath = path.join(workspaceHooksDir, hookFileName("TaskResume"))
const stats = await fs.stat(hookPath)
// Check permissions - should be 0o644 (non-executable)
const mode = stats.mode & 0o777
mode.should.equal(0o644)
}, 5000)
it("should throw error for invalid hook types", async () => {
const request = CreateHookRequest.create({
hookName: "InvalidHookType",
isGlobal: true,
})
try {
await createHook(mockController, request, globalHooksDir)
throw new Error("Should have thrown an error")
} catch (error: any) {
error.message.should.containEql("Invalid hook type")
}
}, 5000)
it("should throw error if hook already exists", async () => {
// Create hook first time
const request = CreateHookRequest.create({
hookName: "UserPromptSubmit",
isGlobal: true,
})
await createHook(mockController, request, globalHooksDir)
// Try to create again
try {
await createHook(mockController, request, globalHooksDir)
throw new Error("Should have thrown an error")
} catch (error: any) {
error.message.should.containEql("already exists")
}
}, 5000)
it("should create parent directories if they don't exist", async () => {
// Remove the hooks directory
await fs.rm(globalHooksDir, { recursive: true, force: true })
const request = CreateHookRequest.create({
hookName: "TaskComplete",
isGlobal: true,
})
await createHook(mockController, request, globalHooksDir)
// Verify directory was created
const dirExists = await fs
.access(globalHooksDir)
.then(() => true)
.catch(() => false)
dirExists.should.equal(true)
// Verify hook was created
const hookPath = path.join(globalHooksDir, hookFileName("TaskComplete"))
const fileExists = await fs
.access(hookPath)
.then(() => true)
.catch(() => false)
fileExists.should.equal(true)
}, 5000)
it("should create workspace hook when isGlobal is false", async () => {
const request = CreateHookRequest.create({
hookName: "TaskCancel",
isGlobal: false,
})
await createHook(mockController, request)
const hookPath = path.join(workspaceHooksDir, hookFileName("TaskCancel"))
const exists = await fs
.access(hookPath)
.then(() => true)
.catch(() => false)
exists.should.equal(true)
}, 5000)
it("should create Notification hook with valid template", async () => {
const request = CreateHookRequest.create({
hookName: "Notification",
isGlobal: true,
})
const response = await createHook(mockController, request, globalHooksDir)
const hookPath = path.join(globalHooksDir, hookFileName("Notification"))
const content = await fs.readFile(hookPath, "utf-8")
content.should.containEql("Notification Hook")
response.hooksToggles!.globalHooks.some((h) => h.name === "Notification").should.equal(true)
}, 5000)
})
describe("deleteHook", () => {
it("should delete existing hook file", async () => {
// Create a hook first
const hookPath = path.join(globalHooksDir, hookFileName("TaskStart"))
await fs.writeFile(hookPath, "#!/usr/bin/env node\nconsole.log('test')", { mode: 0o755 })
const request = DeleteHookRequest.create({
hookName: "TaskStart",
isGlobal: true,
})
const response = await deleteHook(mockController, request, globalHooksDir)
// Verify file was deleted
const exists = await fs
.access(hookPath)
.then(() => true)
.catch(() => false)
exists.should.equal(false)
// Verify response contains updated hooks state
response.should.have.property("hooksToggles")
response.hooksToggles!.globalHooks.should.have.length(0)
}, 5000)
it("should throw error if hook doesn't exist", async () => {
const request = DeleteHookRequest.create({
hookName: "NonExistentHook",
isGlobal: true,
})
try {
await deleteHook(mockController, request, globalHooksDir)
throw new Error("Should have thrown an error")
} catch (error: any) {
error.message.should.containEql("does not exist")
}
}, 5000)
it("should delete workspace hook when isGlobal is false", async () => {
// Create a workspace hook first
const hookPath = path.join(workspaceHooksDir, hookFileName("TaskResume"))
await fs.writeFile(hookPath, "#!/usr/bin/env node\nconsole.log('test')", { mode: 0o755 })
const request = DeleteHookRequest.create({
hookName: "TaskResume",
isGlobal: false,
})
await deleteHook(mockController, request)
const exists = await fs
.access(hookPath)
.then(() => true)
.catch(() => false)
exists.should.equal(false)
}, 5000)
})
describe("toggleHook", () => {
it("should make hook executable (chmod +x)", async () => {
if (isWindows) {
return
}
// Create a non-executable hook
const hookPath = path.join(globalHooksDir, "TaskStart")
await fs.writeFile(hookPath, "#!/usr/bin/env node\nconsole.log('test')", { mode: 0o644 })
const request = ToggleHookRequest.create({
hookName: "TaskStart",
isGlobal: true,
enabled: true,
})
await toggleHook(mockController, request, globalHooksDir)
// Verify file is now executable
const stats = await fs.stat(hookPath)
const mode = stats.mode & 0o777
// Should have at least user execute permission
;(mode & 0o100).should.be.greaterThan(0)
}, 5000)
it("should make hook non-executable (chmod -x)", async () => {
if (isWindows) {
return
}
// Create an executable hook
const hookPath = path.join(globalHooksDir, "TaskResume")
await fs.writeFile(hookPath, "#!/usr/bin/env node\nconsole.log('test')", { mode: 0o755 })
const request = ToggleHookRequest.create({
hookName: "TaskResume",
isGlobal: true,
enabled: false,
})
await toggleHook(mockController, request, globalHooksDir)
// Verify file is now non-executable
const stats = await fs.stat(hookPath)
const mode = stats.mode & 0o777
mode.should.equal(0o644)
}, 5000)
it("should work for workspace hooks", async () => {
if (isWindows) {
return
}
// Create a workspace hook
const hookPath = path.join(workspaceHooksDir, "UserPromptSubmit")
await fs.writeFile(hookPath, "#!/usr/bin/env node\nconsole.log('test')", { mode: 0o644 })
const request = ToggleHookRequest.create({
hookName: "UserPromptSubmit",
isGlobal: false,
enabled: true,
})
await toggleHook(mockController, request)
const stats = await fs.stat(hookPath)
const mode = stats.mode & 0o777
;(mode & 0o100).should.be.greaterThan(0)
}, 5000)
it("should return updated hooks state", async () => {
const hookPath = path.join(globalHooksDir, hookFileName("TaskComplete"))
await fs.writeFile(hookPath, "#!/usr/bin/env node\nconsole.log('test')", { mode: 0o644 })
const request = ToggleHookRequest.create({
hookName: "TaskComplete",
isGlobal: true,
enabled: true,
})
const response = await toggleHook(mockController, request, globalHooksDir)
response.should.have.property("hooksToggles")
response.hooksToggles!.globalHooks.should.have.length(1)
response.hooksToggles!.globalHooks[0].enabled.should.equal(true)
}, 5000)
})
describe("refreshHooks", () => {
it("should discover hooks in global directory", async () => {
// Create some hooks
if (isWindows) {
await fs.writeFile(path.join(globalHooksDir, "TaskStart.ps1"), "Write-Output '{}'", { mode: 0o644 })
await fs.writeFile(path.join(globalHooksDir, "TaskResume.ps1"), "Write-Output '{}'", { mode: 0o644 })
} else {
await fs.writeFile(path.join(globalHooksDir, "TaskStart"), "#!/usr/bin/env node", { mode: 0o755 })
await fs.writeFile(path.join(globalHooksDir, "TaskResume"), "#!/usr/bin/env node", { mode: 0o644 })
}
const result = await refreshHooks(mockController, undefined, globalHooksDir)
result.globalHooks.should.have.length(2)
result.globalHooks[0].name.should.equal("TaskStart")
result.globalHooks[0].enabled.should.equal(true)
result.globalHooks[1].name.should.equal("TaskResume")
if (isWindows) {
result.globalHooks[1].enabled.should.equal(true)
} else {
result.globalHooks[1].enabled.should.equal(false)
}
}, 5000)
it("should discover hooks in workspace directories", async () => {
await fs.writeFile(path.join(workspaceHooksDir, hookFileName("UserPromptSubmit")), hookTemplate, {
mode: 0o755,
})
const result = await refreshHooks(mockController, undefined)
result.workspaceHooks.should.have.length(1)
result.workspaceHooks[0].hooks.should.have.length(1)
result.workspaceHooks[0].hooks[0].name.should.equal("UserPromptSubmit")
result.workspaceHooks[0].hooks[0].enabled.should.equal(true)
}, 5000)
it("should correctly identify executable vs non-executable hooks", async () => {
if (isWindows) {
return
}
await fs.writeFile(path.join(globalHooksDir, "TaskStart"), "#!/usr/bin/env node", { mode: 0o755 })
await fs.writeFile(path.join(globalHooksDir, "TaskCancel"), "#!/usr/bin/env node", { mode: 0o644 })
const result = await refreshHooks(mockController, undefined, globalHooksDir)
const taskStart = result.globalHooks.find((h) => h.name === "TaskStart")
const taskCancel = result.globalHooks.find((h) => h.name === "TaskCancel")
taskStart!.enabled.should.equal(true)
taskCancel!.enabled.should.equal(false)
}, 5000)
it("should return empty list when no hooks exist", async () => {
const result = await refreshHooks(mockController, undefined, globalHooksDir)
result.globalHooks.should.have.length(0)
// Workspace should still appear even with no hooks
result.workspaceHooks.should.have.length(1)
result.workspaceHooks[0].hooks.should.have.length(0)
}, 5000)
it("should include absolute paths in hook info", async () => {
if (isWindows) {
await fs.writeFile(path.join(globalHooksDir, "TaskComplete.ps1"), "Write-Output '{}'", { mode: 0o644 })
} else {
await fs.writeFile(path.join(globalHooksDir, "TaskComplete"), "#!/usr/bin/env node", { mode: 0o755 })
}
const result = await refreshHooks(mockController, undefined, globalHooksDir)
result.globalHooks[0].absolutePath.should.equal(path.join(globalHooksDir, hookFileName("TaskComplete")))
}, 5000)
it("should set isWindows flag correctly", async () => {
const result = await refreshHooks(mockController, undefined)
result.isWindows.should.equal(isWindows)
}, 5000)
it("should resolve .ps1-only hooks on Windows (extensionless is unsupported)", async () => {
await withPlatform("win32", async () => {
await fs.writeFile(path.join(globalHooksDir, "TaskStart.ps1"), "Write-Output '{}'", { mode: 0o644 })
const result = await refreshHooks(mockController, undefined, globalHooksDir)
const taskStart = result.globalHooks.find((h) => h.name === "TaskStart")
should.exist(taskStart)
taskStart!.absolutePath.should.equal(path.join(globalHooksDir, "TaskStart.ps1"))
taskStart!.enabled.should.equal(true)
})
}, 5000)
it("should ignore extensionless hook on Windows and use .ps1 only", async () => {
await withPlatform("win32", async () => {
await fs.writeFile(path.join(globalHooksDir, "TaskResume"), "Write-Output '{}'", { mode: 0o644 })
await fs.writeFile(path.join(globalHooksDir, "TaskResume.ps1"), "Write-Output '{}'", { mode: 0o644 })
const result = await refreshHooks(mockController, undefined, globalHooksDir)
const taskResume = result.globalHooks.find((h) => h.name === "TaskResume")
should.exist(taskResume)
taskResume!.absolutePath.should.equal(path.join(globalHooksDir, "TaskResume.ps1"))
})
}, 5000)
})
describe("Edge Cases", () => {
it("should handle missing .clinerules directory gracefully", async () => {
// Remove workspace hooks directory
await fs.rm(path.dirname(workspaceHooksDir), { recursive: true, force: true })
const result = await refreshHooks(mockController, undefined)
// Should not throw, just return empty workspace hooks
result.workspaceHooks.should.have.length(1)
result.workspaceHooks[0].hooks.should.have.length(0)
}, 5000)
it("should handle permission errors gracefully", async () => {
if (isWindows) {
return
}
// Create a hook
const hookPath = path.join(globalHooksDir, "TaskStart")
await fs.writeFile(hookPath, "#!/usr/bin/env node", { mode: 0o644 })
// Make the hooks directory read-only on unix systems
if (process.platform !== "win32") {
await fs.chmod(globalHooksDir, 0o444)
}
const request = DeleteHookRequest.create({
hookName: "TaskStart",
isGlobal: true,
})
try {
await deleteHook(mockController, request, globalHooksDir)
// Should throw an error
} catch (error) {
// Expected - permission denied
} finally {
// Restore permissions for cleanup
await fs.chmod(globalHooksDir, 0o755)
}
}, 5000)
it("should invalidate cache after hook operations", async () => {
// Create a hook
const createRequest = CreateHookRequest.create({
hookName: "TaskStart",
isGlobal: true,
})
await createHook(mockController, createRequest, globalHooksDir)
// Refresh should see the new hook
let result = await refreshHooks(mockController, undefined, globalHooksDir)
result.globalHooks.should.have.length(1)
// Delete the hook
const deleteRequest = DeleteHookRequest.create({
hookName: "TaskStart",
isGlobal: true,
})
await deleteHook(mockController, deleteRequest, globalHooksDir)
// Refresh should no longer see the hook
result = await refreshHooks(mockController, undefined, globalHooksDir)
result.globalHooks.should.have.length(0)
}, 5000)
it("should toggle and delete .ps1-only hooks on Windows", async () => {
await withPlatform("win32", async () => {
const ps1Path = path.join(globalHooksDir, "TaskCancel.ps1")
await fs.writeFile(ps1Path, "Write-Output '{}'", { mode: 0o644 })
const toggleResponse = await toggleHook(
mockController,
ToggleHookRequest.create({
hookName: "TaskCancel",
isGlobal: true,
enabled: true,
}),
globalHooksDir,
)
const toggled = toggleResponse.hooksToggles!.globalHooks.find((h) => h.name === "TaskCancel")
should.exist(toggled)
toggled!.absolutePath.should.equal(ps1Path)
await deleteHook(
mockController,
DeleteHookRequest.create({
hookName: "TaskCancel",
isGlobal: true,
}),
globalHooksDir,
)
const exists = await fs
.access(ps1Path)
.then(() => true)
.catch(() => false)
exists.should.equal(false)
})
}, 5000)
})
})
-57
View File
@@ -256,61 +256,4 @@ describe("Filesystem Utilities", () => {
multiExcludeFiles.sort().should.deepEqual(rootOnlyFiles.sort())
})
it("should exclude .clinerules/hooks directory specifically", async () => {
// Create a test directory structure
const clinerulesDirTest = path.join(tmpDir, "clinerules-hooks-test")
const clinerulesDirPath = path.join(clinerulesDirTest, ".clinerules")
// Create .clinerules directory and root files
await fs.mkdir(clinerulesDirPath, { recursive: true })
await fs.writeFile(path.join(clinerulesDirPath, "config.json"), "{}")
await fs.writeFile(path.join(clinerulesDirPath, "settings.js"), "// settings")
// Create .clinerules/workflows directory and files
const workflowsDirPath = path.join(clinerulesDirPath, "workflows")
await fs.mkdir(workflowsDirPath, { recursive: true })
await fs.writeFile(path.join(workflowsDirPath, "workflow1.js"), "// workflow1")
// Create .clinerules/hooks directory and files
const hooksDirPath = path.join(clinerulesDirPath, "hooks")
await fs.mkdir(hooksDirPath, { recursive: true })
await fs.writeFile(path.join(hooksDirPath, "PreToolUse"), "#!/usr/bin/env bash")
await fs.writeFile(path.join(hooksDirPath, "PostToolUse"), "#!/usr/bin/env bash")
// Get all files WITHOUT exclusion
const allFiles = await readDirectory(clinerulesDirPath)
// Verify all files are included
allFiles.length.should.equal(5) // 2 in root + 1 in workflows + 2 in hooks
allFiles.some((file) => file.includes("PreToolUse")).should.be.true()
allFiles.some((file) => file.includes("PostToolUse")).should.be.true()
// Get files WITH hooks directory excluded
const filteredFiles = await readDirectory(clinerulesDirPath, [[".clinerules", "hooks"]])
// Verify hooks files are excluded but others remain
filteredFiles.length.should.equal(3) // 2 in root + 1 in workflows
const expectedFiles = [
path.resolve(clinerulesDirPath, "config.json"),
path.resolve(clinerulesDirPath, "settings.js"),
path.resolve(workflowsDirPath, "workflow1.js"),
]
filteredFiles.sort().should.deepEqual(expectedFiles.sort())
// Test with multiple exclusions (both workflows and hooks)
const multiExcludeFiles = await readDirectory(clinerulesDirPath, [
[".clinerules", "workflows"],
[".clinerules", "hooks"],
])
// Verify both workflows and hooks directories are excluded
multiExcludeFiles.length.should.equal(2) // only the 2 files in root
const rootOnlyFiles = [path.resolve(clinerulesDirPath, "config.json"), path.resolve(clinerulesDirPath, "settings.js")]
multiExcludeFiles.sort().should.deepEqual(rootOnlyFiles.sort())
})
})
@@ -71,7 +71,6 @@ const ChatView = ({ isHidden, showAnnouncement, hideAnnouncement, showHistoryVie
telemetrySetting,
mode,
userInfo,
hooksEnabled,
checkpointRestoreInput,
queuedPrompts,
} = useExtensionState()
@@ -122,10 +121,9 @@ const ChatView = ({ isHidden, showAnnouncement, hideAnnouncement, showHistoryVie
const task = useMemo(() => messages.at(0), [messages]) // leaving this less safe version here since if the first message is not a task, then the extension is in a bad state and needs to be debugged (see Cline.abort)
const modifiedMessages = useMemo(() => {
const slicedMessages = displayMessages.slice(1)
// Only combine hook sequences if hooks are enabled
const withHooks = hooksEnabled ? combineHookSequences(slicedMessages) : slicedMessages
const withHooks = combineHookSequences(slicedMessages)
return combineErrorRetryMessages(combineApiRequests(combineCommandSequences(withHooks)))
}, [displayMessages, hooksEnabled])
}, [displayMessages])
// has to be after api_req_finished are all reduced into api_req_started messages
const apiMetrics = useMemo(() => getApiMetrics(modifiedMessages), [modifiedMessages])
@@ -1,6 +1,7 @@
import { EmptyRequest } from "@shared/proto/cline/common"
import {
ClineRulesToggles,
HookInfo,
RefreshedRules,
RuleScope,
SkillInfo,
@@ -20,7 +21,6 @@ import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip
import { useExtensionState } from "@/context/ExtensionStateContext"
import useRemoteConfigSettings from "@/hooks/useRemoteConfigSettings"
import { FileServiceClient } from "@/services/grpc-client"
import { isMacOSOrLinux } from "@/utils/platformUtils"
import HookRow from "./HookRow"
import NewRuleRow from "./NewRuleRow"
import RuleRow from "./RuleRow"
@@ -35,7 +35,6 @@ const ClineRulesToggleModal: React.FC = () => {
localAgentsRulesToggles = {},
localWorkflowToggles = {},
globalWorkflowToggles = {},
hooksEnabled,
setGlobalClineRulesToggles,
setLocalClineRulesToggles,
setLocalCursorRulesToggles,
@@ -47,14 +46,11 @@ const ClineRulesToggleModal: React.FC = () => {
setLocalSkillsToggles,
setRemoteRulesToggles,
} = useExtensionState()
const [globalHooks, setGlobalHooks] = useState<Array<{ name: string; enabled: boolean; absolutePath: string }>>([])
const [workspaceHooks, setWorkspaceHooks] = useState<
Array<{ workspaceName: string; hooks: Array<{ name: string; enabled: boolean; absolutePath: string }> }>
>([])
const [globalHooks, setGlobalHooks] = useState<HookInfo[]>([])
const [workspaceHooks, setWorkspaceHooks] = useState<Array<{ workspaceName: string; hooks: HookInfo[] }>>([])
const [globalSkills, setGlobalSkills] = useState<SkillInfo[]>([])
const [localSkills, setLocalSkills] = useState<SkillInfo[]>([])
const isWindows = !isMacOSOrLinux()
const [isVisible, setIsVisible] = useState(false)
const buttonRef = useRef<HTMLDivElement>(null)
const modalRef = useRef<HTMLDivElement>(null)
@@ -63,13 +59,6 @@ const ClineRulesToggleModal: React.FC = () => {
const [menuPosition, setMenuPosition] = useState(0)
const [currentView, setCurrentView] = useState<"rules" | "workflows" | "hooks" | "skills">("rules")
// Auto-switch to rules tab if hooks become disabled while viewing hooks tab
useEffect(() => {
if (currentView === "hooks" && !hooksEnabled) {
setCurrentView("rules")
}
}, [currentView, hooksEnabled])
useEffect(() => {
if (isVisible) {
FileServiceClient.refreshRules({} as EmptyRequest)
@@ -302,24 +291,6 @@ const ClineRulesToggleModal: React.FC = () => {
})
}
// Toggle hook handler
const toggleHook = (isGlobal: boolean, hookName: string, enabled: boolean, workspaceName?: string) => {
FileServiceClient.toggleHook({
metadata: {} as any,
hookName,
isGlobal,
enabled,
workspaceName,
})
.then((response) => {
setGlobalHooks(response.hooksToggles?.globalHooks || [])
setWorkspaceHooks(response.hooksToggles?.workspaceHooks || [])
})
.catch((error) => {
console.error("Error toggling hook:", error)
})
}
const toggleWorkflow = (isGlobal: boolean, workflowPath: string, enabled: boolean) => {
FileServiceClient.toggleWorkflow(
ToggleWorkflowRequest.create({
@@ -431,11 +402,9 @@ const ClineRulesToggleModal: React.FC = () => {
<TabButton isActive={currentView === "workflows"} onClick={() => setCurrentView("workflows")}>
Workflows
</TabButton>
{hooksEnabled && (
<TabButton isActive={currentView === "hooks"} onClick={() => setCurrentView("hooks")}>
Hooks
</TabButton>
)}
<TabButton isActive={currentView === "hooks"} onClick={() => setCurrentView("hooks")}>
Hooks
</TabButton>
<TabButton isActive={currentView === "skills"} onClick={() => setCurrentView("skills")}>
Skills
</TabButton>
@@ -643,9 +612,7 @@ const ClineRulesToggleModal: React.FC = () => {
<>
<div className="text-xs text-description mb-4">
<p>
{isWindows
? "On Windows, hooks execute whenever the hook file exists."
: "Toggle to enable/disable (chmod +x/-x)."}{" "}
Hooks are discovered from SDK hook config paths.{" "}
<VSCodeLink
className="text-xs"
href="https://docs.cline.bot/features/hooks"
@@ -655,17 +622,6 @@ const ClineRulesToggleModal: React.FC = () => {
</p>
</div>
{/* Hooks Tab */}
{/* Windows warning banner */}
{isWindows && (
<div className="flex items-center gap-2 px-3 py-3 mb-4 bg-vscode-inputValidation-warningBackground border-l-[3px] border-vscode-inputValidation-warningBorder">
<i className="codicon codicon-warning text-sm" />
<span className="text-base">
Hook toggling is not yet supported on Windows in this foundation PR. Hooks can be
created, edited, and deleted, and execute whenever the hook file exists. Coming next:
JSON-backed hook enabled/disabled state across platforms.
</span>
</div>
)}
{/* Global Hooks */}
<div className="mb-3">
@@ -676,26 +632,14 @@ const ClineRulesToggleModal: React.FC = () => {
.map((hook) => (
<HookRow
absolutePath={hook.absolutePath}
enabled={hook.enabled}
hookEventName={hook.hookEventName}
hookName={hook.name}
isGlobal={true}
isWindows={isWindows}
key={hook.name}
onDelete={(hooksToggles) => {
// Use response data directly, no need to refresh
setGlobalHooks(hooksToggles.globalHooks || [])
setWorkspaceHooks(hooksToggles.workspaceHooks || [])
}}
onToggle={(name: string, newEnabled: boolean) =>
toggleHook(true, name, newEnabled)
}
key={hook.absolutePath}
/>
))}
<NewRuleRow
existingHooks={globalHooks.map((h) => h.name)}
isGlobal={true}
ruleType="hook"
/>
{globalHooks.length === 0 && (
<div className="text-xs text-description mb-2">No global hooks found.</div>
)}
</div>
</div>
@@ -704,37 +648,21 @@ const ClineRulesToggleModal: React.FC = () => {
<div
className={index === workspaceHooks.length - 1 ? "-mb-2.5" : "mb-3"}
key={workspace.workspaceName}>
<div className="text-sm font-normal mb-2">
{workspace.workspaceName}/.clinerules/hooks/
</div>
<div className="text-sm font-normal mb-2">{workspace.workspaceName} Workspace Hooks</div>
<div className="flex flex-col gap-0">
{workspace.hooks
.sort((a, b) => a.name.localeCompare(b.name))
.map((hook) => (
<HookRow
absolutePath={hook.absolutePath}
enabled={hook.enabled}
hookEventName={hook.hookEventName}
hookName={hook.name}
isGlobal={false}
isWindows={isWindows}
key={hook.absolutePath}
onDelete={(hooksToggles) => {
// Use response data directly, no need to refresh
setGlobalHooks(hooksToggles.globalHooks || [])
setWorkspaceHooks(hooksToggles.workspaceHooks || [])
}}
onToggle={(name: string, newEnabled: boolean) =>
toggleHook(false, name, newEnabled, workspace.workspaceName)
}
workspaceName={workspace.workspaceName}
/>
))}
<NewRuleRow
existingHooks={workspace.hooks.map((h) => h.name)}
isGlobal={false}
ruleType="hook"
workspaceName={workspace.workspaceName}
/>
{workspace.hooks.length === 0 && (
<div className="text-xs text-description mb-2">No workspace hooks found.</div>
)}
</div>
</div>
))}
@@ -1,88 +1,33 @@
import { StringRequest } from "@shared/proto/cline/common"
import { DeleteHookRequest, HooksToggles } from "@shared/proto/cline/file"
import { PenIcon, Trash2Icon } from "lucide-react"
import { PenIcon } from "lucide-react"
import { Button } from "@/components/ui/button"
import { Switch } from "@/components/ui/switch"
import { FileServiceClient } from "@/services/grpc-client"
interface HookRowProps {
hookName: string
enabled: boolean
hookEventName?: string
absolutePath: string
isGlobal: boolean
isWindows: boolean
workspaceName?: string
onToggle: (hookName: string, newEnabled: boolean) => void
onDelete: (hooksToggles: HooksToggles) => void
}
const HookRow: React.FC<HookRowProps> = ({
hookName,
enabled,
absolutePath,
isGlobal,
isWindows,
workspaceName,
onToggle,
onDelete,
}) => {
const HookRow: React.FC<HookRowProps> = ({ hookName, hookEventName, absolutePath }) => {
const handleEditClick = () => {
FileServiceClient.openFile(StringRequest.create({ value: absolutePath })).catch((err) =>
console.error("Failed to open file:", err),
)
}
const handleDeleteClick = () => {
FileServiceClient.deleteHook(
DeleteHookRequest.create({
hookName,
isGlobal,
workspaceName,
}),
)
.then((response) => {
if (response.hooksToggles) {
onDelete(response.hooksToggles)
}
})
.catch((err) => console.error("Failed to delete hook:", err))
}
return (
<div className="mb-2.5">
<div className="flex items-center px-2 py-4 rounded bg-text-block-background max-h-4">
<span className="flex-1 overflow-hidden break-all whitespace-normal flex items-center mr-1">
<span className="ph-no-capture">{hookName}</span>
<span className="flex-1 min-w-0 overflow-hidden break-all whitespace-normal flex items-center gap-2 mr-1">
<span className="ph-no-capture font-medium">{hookName}</span>
{hookEventName && <span className="text-xs text-description ph-no-capture">{hookEventName}</span>}
</span>
{/* Toggle Switch */}
<div className="flex items-center space-x-2 gap-2">
<div
title={
isWindows
? "Hook toggling is not yet supported on Windows in this foundation PR. Hooks execute when the hook file exists."
: undefined
}>
<Switch
checked={enabled}
className="mx-1"
disabled={isWindows}
key={hookName}
onClick={() => onToggle(hookName, !enabled)}
style={isWindows ? { opacity: 0.5, cursor: "not-allowed" } : undefined}
/>
</div>
<Button aria-label="Edit hook file" onClick={handleEditClick} size="xs" title="Edit hook file" variant="icon">
<PenIcon />
</Button>
<Button
aria-label="Delete hook file"
onClick={handleDeleteClick}
size="xs"
title="Delete hook file"
variant="icon">
<Trash2Icon />
</Button>
</div>
</div>
</div>
@@ -1,6 +1,6 @@
import { CreateHookRequest, CreateSkillRequest, RuleFileRequest } from "@shared/proto/index.cline"
import { CreateSkillRequest, RuleFileRequest } from "@shared/proto/index.cline"
import { PlusIcon } from "lucide-react"
import { useEffect, useMemo, useRef, useState } from "react"
import { useEffect, useRef, useState } from "react"
import { useClickAway } from "react-use"
import { Button } from "@/components/ui/button"
import { cn } from "@/lib/utils"
@@ -9,22 +9,9 @@ import { FileServiceClient } from "@/services/grpc-client"
interface NewRuleRowProps {
isGlobal: boolean
ruleType?: string
existingHooks?: string[]
workspaceName?: string
}
const HOOK_TYPES = [
{ name: "TaskStart", description: "Executes when a new task begins" },
{ name: "TaskResume", description: "Executes when a task is resumed" },
{ name: "TaskCancel", description: "Executes when a task is cancelled" },
{ name: "TaskComplete", description: "Executes when a task completes" },
{ name: "PreToolUse", description: "Executes before any tool is used" },
{ name: "PostToolUse", description: "Executes after any tool is used" },
{ name: "UserPromptSubmit", description: "Executes when user submits a prompt" },
{ name: "PreCompact", description: "Executes before conversation compaction" },
]
const NewRuleRow: React.FC<NewRuleRowProps> = ({ isGlobal, ruleType, existingHooks = [], workspaceName }) => {
const NewRuleRow: React.FC<NewRuleRowProps> = ({ isGlobal, ruleType }) => {
const [isExpanded, setIsExpanded] = useState(false)
const [filename, setFilename] = useState("")
const inputRef = useRef<HTMLInputElement>(null)
@@ -32,9 +19,6 @@ const NewRuleRow: React.FC<NewRuleRowProps> = ({ isGlobal, ruleType, existingHoo
const componentRef = useRef<HTMLDivElement>(null)
// Calculate available hook types by filtering out existing hooks
const availableHookTypes = useMemo(() => HOOK_TYPES.filter((type) => !existingHooks.includes(type.name)), [existingHooks])
// Focus the input when expanded
useEffect(() => {
if (isExpanded && inputRef.current) {
@@ -62,22 +46,6 @@ const NewRuleRow: React.FC<NewRuleRowProps> = ({ isGlobal, ruleType, existingHoo
return ext === "" || ext === ".md" || ext === ".txt"
}
const handleCreateHook = async (hookName: string) => {
if (!hookName) return
try {
await FileServiceClient.createHook(
CreateHookRequest.create({
hookName,
isGlobal,
workspaceName,
}),
)
} catch (err) {
console.error("Error creating hook:", err)
}
}
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault()
@@ -161,102 +129,59 @@ const NewRuleRow: React.FC<NewRuleRowProps> = ({ isGlobal, ruleType, existingHoo
"shadow-sm": isExpanded,
},
)}>
{ruleType === "hook" ? (
<>
<label className="sr-only" htmlFor="hook-type-select">
Select hook type to create
</label>
<span className="sr-only" id="hook-select-description">
Choose a hook type to create. Hooks execute at specific points in Cline's lifecycle. Available:{" "}
{availableHookTypes.map((h) => h.name).join(", ")}
</span>
<select
aria-describedby="hook-select-description"
aria-label="Select hook type to create"
className="flex-1 bg-input-background text-input-foreground border-0 outline-0 rounded focus:outline-none focus:ring-0 focus:border-transparent px-2 cursor-pointer"
disabled={availableHookTypes.length === 0}
id="hook-type-select"
onChange={(e) => {
if (e.target.value) {
handleCreateHook(e.target.value)
// Reset selection after creating
e.target.value = ""
}
}}
style={{
fontStyle: "italic",
appearance: "none",
backgroundImage: `url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 12 12'%3E%3Cpath fill='%23cccccc' d='M6 9L1 4h10z'/%3E%3C/svg%3E")`,
backgroundRepeat: "no-repeat",
backgroundPosition: "right 8px center",
paddingRight: "24px",
}}
value="">
<option disabled value="">
{availableHookTypes.length === 0 ? "All hooks created" : "New hook..."}
</option>
{availableHookTypes.map((hook) => (
<option key={hook.name} title={hook.description} value={hook.name}>
{hook.name}
</option>
))}
</select>
</>
) : (
<form className="flex flex-1 items-center" onSubmit={handleSubmit}>
<input
className={cn(
"flex-1 bg-input-background text-input-foreground border-0 outline-0 rounded focus:outline-none focus:ring-0 focus:border-transparent",
{
italic: !isExpanded,
},
)}
onChange={(e) => setFilename(e.target.value)}
placeholder={
isExpanded
? ruleType === "workflow"
? "workflow-name (.md, .txt, or no extension)"
: ruleType === "skill"
? "skill-name (letters, numbers, dashes, underscores)"
: "rule-name (.md, .txt, or no extension)"
: ruleType === "workflow"
? "New workflow file..."
: ruleType === "skill"
? "New skill..."
: "New rule file..."
}
ref={inputRef}
type="text"
value={isExpanded ? filename : ""}
/>
<form className="flex flex-1 items-center" onSubmit={handleSubmit}>
<input
className={cn(
"flex-1 bg-input-background text-input-foreground border-0 outline-0 rounded focus:outline-none focus:ring-0 focus:border-transparent",
{
italic: !isExpanded,
},
)}
onChange={(e) => setFilename(e.target.value)}
placeholder={
isExpanded
? ruleType === "workflow"
? "workflow-name (.md, .txt, or no extension)"
: ruleType === "skill"
? "skill-name (letters, numbers, dashes, underscores)"
: "rule-name (.md, .txt, or no extension)"
: ruleType === "workflow"
? "New workflow file..."
: ruleType === "skill"
? "New skill..."
: "New rule file..."
}
ref={inputRef}
type="text"
value={isExpanded ? filename : ""}
/>
<Button
aria-label={
isExpanded
? ruleType === "skill"
? "Create skill"
: "Create file"
: ruleType === "workflow"
? "New workflow file..."
: ruleType === "skill"
? "New skill..."
: "New rule file..."
<Button
aria-label={
isExpanded
? ruleType === "skill"
? "Create skill"
: "Create file"
: ruleType === "workflow"
? "New workflow file..."
: ruleType === "skill"
? "New skill..."
: "New rule file..."
}
className="mx-0.5"
onClick={(e) => {
e.stopPropagation()
if (!isExpanded) {
setIsExpanded(true)
}
className="mx-0.5"
onClick={(e) => {
e.stopPropagation()
if (!isExpanded) {
setIsExpanded(true)
}
}}
size="icon"
title={isExpanded ? (ruleType === "skill" ? "Create skill" : "Create file") : "New file"}
type={isExpanded ? "submit" : "button"}
variant="icon">
<PlusIcon />
</Button>
</form>
)}
}}
size="icon"
title={isExpanded ? (ruleType === "skill" ? "Create skill" : "Create file") : "New file"}
type={isExpanded ? "submit" : "button"}
variant="icon">
<PlusIcon />
</Button>
</form>
</div>
{isExpanded && error && <div className="text-error text-xs mt-1 ml-2">{error}</div>}
</div>
@@ -7,7 +7,6 @@ const mockUpdateSetting = vi.fn()
vi.mock("@/context/ExtensionStateContext", () => ({
useExtensionState: vi.fn(() => ({
enableCheckpointsSetting: true,
hooksEnabled: false,
showFeatureTips: false,
mcpDisplayMode: "rich",
yoloModeToggled: false,
@@ -25,15 +24,15 @@ vi.mock("../utils/settingsHandlers", () => ({
}))
describe("FeatureSettingsSection", () => {
it("renders Hooks feature toggle", () => {
it("does not render a legacy Hooks feature toggle", () => {
const { container } = render(<FeatureSettingsSection renderSectionHeader={() => null} />)
expect(screen.getByText("Hooks")).toBeTruthy()
expect(screen.queryByText("Hooks")).toBeNull()
const advancedSection = container.querySelector("#advanced-features")
const agentSection = container.querySelector("#agent-features")
expect(advancedSection?.querySelector("#Hooks")).toBeTruthy()
expect(advancedSection?.querySelector("#Hooks")).toBeNull()
expect(agentSection?.querySelector("#Hooks")).toBeNull()
})
@@ -49,17 +48,6 @@ describe("FeatureSettingsSection", () => {
expect(agentSection?.querySelector('[id="Feature Tips"]')).toBeNull()
})
it("calls updateSetting with hooksEnabled when toggled", () => {
const { container } = render(<FeatureSettingsSection renderSectionHeader={() => null} />)
const hooksSwitch = container.querySelector("#Hooks")
expect(hooksSwitch).toBeTruthy()
fireEvent.click(hooksSwitch as Element)
expect(mockUpdateSetting).toHaveBeenCalledWith("hooksEnabled", true)
})
it("calls updateSetting with showFeatureTips when toggled", () => {
const { container } = render(<FeatureSettingsSection renderSectionHeader={() => null} />)
@@ -81,16 +81,6 @@ const experimentalFeatures: FeatureToggle[] = [
},
]
const advancedFeatures: FeatureToggle[] = [
{
id: "hooks",
label: "Hooks",
description: "Enable lifecycle and tool hooks during task execution.",
stateKey: "hooksEnabled",
settingKey: "hooksEnabled",
},
]
const FeatureRow = memo(
({
checked = false,
@@ -150,7 +140,6 @@ interface FeatureSettingsSectionProps {
const FeatureSettingsSection = ({ renderSectionHeader }: FeatureSettingsSectionProps) => {
const {
enableCheckpointsSetting,
hooksEnabled,
mcpDisplayMode,
yoloModeToggled,
useAutoCondense,
@@ -167,7 +156,6 @@ const FeatureSettingsSection = ({ renderSectionHeader }: FeatureSettingsSectionP
const featureState: Record<string, boolean | undefined> = {
showFeatureTips,
enableCheckpointsSetting,
hooksEnabled,
useAutoCondense,
subagentsEnabled,
worktreesEnabled: worktreesEnabled?.user,
@@ -251,17 +239,6 @@ const FeatureSettingsSection = ({ renderSectionHeader }: FeatureSettingsSectionP
<div className="text-xs font-medium text-foreground/80 uppercase tracking-wider mb-3">Advanced</div>
<div className="relative p-3 my-3 rounded-md border border-editor-widget-border/50" id="advanced-features">
<div className="space-y-3">
{advancedFeatures.map((feature) => (
<FeatureRow
checked={featureState[feature.stateKey]}
description={feature.description}
isVisible={featureVisibility[feature.stateKey] ?? true}
key={feature.id}
label={feature.label}
onChange={(checked) => updateSetting(feature.settingKey, checked)}
/>
))}
{/* MCP Display Mode */}
<div className="space-y-2">
<Label className="text-sm font-medium text-foreground">MCP Display Mode</Label>
@@ -321,7 +321,6 @@ export const ExtensionStateContextProvider: React.FC<{
primaryRootIndex: 0,
isMultiRootWorkspace: false,
multiRootSetting: { user: false, featureFlag: false },
hooksEnabled: false,
})
const [expandTaskHeader, setExpandTaskHeader] = useState(true)
const [didHydrateState, setDidHydrateState] = useState(false)