mirror of
https://github.com/cline/cline.git
synced 2026-09-13 09:50:12 +08:00
Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
35bf0864ea | ||
|
|
bc0d8fbc6b |
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}));
|
||||
-8
@@ -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);
|
||||
-7
@@ -1,7 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
// Note: For TaskCancel, contextModification is completely ignored.
|
||||
|
||||
console.log(JSON.stringify({
|
||||
cancel: true,
|
||||
errorMessage: ""
|
||||
}));
|
||||
-7
@@ -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"
|
||||
}));
|
||||
-7
@@ -1,7 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
// Note: For TaskCancel, contextModification is completely ignored.
|
||||
|
||||
console.log(JSON.stringify({
|
||||
cancel: false,
|
||||
errorMessage: ""
|
||||
}));
|
||||
-7
@@ -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"
|
||||
}));
|
||||
-7
@@ -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: ""
|
||||
}));
|
||||
-11
@@ -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: ""
|
||||
}));
|
||||
-9
@@ -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: ""
|
||||
}));
|
||||
-9
@@ -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: ""
|
||||
}));
|
||||
-13
@@ -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: ""
|
||||
}));
|
||||
-7
@@ -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"
|
||||
}));
|
||||
-7
@@ -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: ""
|
||||
}));
|
||||
-8
@@ -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: ""
|
||||
}));
|
||||
-3
@@ -1,3 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
console.error("Hook execution error");
|
||||
process.exit(1);
|
||||
-8
@@ -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: ""
|
||||
}));
|
||||
-2
@@ -1,2 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
console.log("not valid json");
|
||||
-8
@@ -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: ""
|
||||
}));
|
||||
-9
@@ -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: ""
|
||||
}));
|
||||
-7
@@ -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)
|
||||
}
|
||||
@@ -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":""}'
|
||||
`
|
||||
}
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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",
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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>
|
||||
|
||||
+3
-15
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user