Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/bright-cars-race.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@kilocode/cli": patch
---

Add CLI lifecycle hooks for custom automation commands.
25 changes: 25 additions & 0 deletions cli/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -128,6 +128,31 @@ kilocode --auto "Refactor the auth module" --on-task-completed "Commit all chang
- The agent has 90 seconds to complete the follow-up action
- Supports markdown, special characters, and multi-line prompts

#### Hooks Configuration

You can register lifecycle hooks in your CLI config (`~/.kilocode/cli/config.json` or `<workspace>/.kilocode/cli/config.json`). Project hooks are appended after global hooks.

```json
{
"hooks": {
"UserPromptSubmit": [
{
"matcher": "*",
"hooks": [{ "type": "command", "command": "echo \"$KILO_HOOK\" >> /tmp/kilo.log" }]
}
],
"PreToolUse": [
{
"matcher": "Write|Edit",
"hooks": [{ "type": "command", "command": "node ./scripts/validate-tool.js", "timeout": 30000 }]
}
]
}
}
```

Supported hook events: `PreToolUse`, `PostToolUse`, `PermissionRequest`, `Notification`, `UserPromptSubmit`, `Stop`, `PreCompact`, `SessionStart`, `SessionEnd`.

#### Autonomous mode Behavior

When running in Autonomous mode (`--auto` flag):
Expand Down
61 changes: 61 additions & 0 deletions cli/src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,8 @@ import { getKiloToken } from "./config/persistence.js"
import { SessionManager } from "../../src/shared/kilocode/cli-sessions/core/SessionManager.js"
import { triggerExitConfirmationAtom } from "./state/atoms/keyboard.js"
import { randomUUID } from "crypto"
import { loadHooks, runSessionStartHooks, runSessionEndHooks, type HooksConfig } from "./hooks/index.js"
import { setHooksConfigAtom, setHooksSessionIdAtom, setHooksWorkspaceAtom } from "./state/atoms/hooks.js"

/**
* Main application class that orchestrates the CLI lifecycle
Expand All @@ -49,11 +51,34 @@ export class CLI {
private options: CLIOptions
private isInitialized = false
private sessionService: SessionManager | null = null
private hooksConfig: HooksConfig = {}
private sessionId: string | null = null

constructor(options: CLIOptions = {}) {
this.options = options
}

/**
* Get the loaded hooks configuration
*/
getHooksConfig(): HooksConfig {
return this.hooksConfig
}

/**
* Get the current session ID
*/
getSessionId(): string | null {
return this.sessionId
}

/**
* Get the workspace path
*/
getWorkspace(): string {
return this.options.workspace || process.cwd()
}

/**
* Initialize the application
* - Creates ExtensionService
Expand Down Expand Up @@ -91,6 +116,17 @@ export class CLI {
let config = await this.store.set(loadConfigAtom, this.options.mode)
logs.debug("CLI configuration loaded", "CLI", { mode: this.options.mode })

// Load lifecycle hooks from global and project configs
const workspace = this.options.workspace || process.cwd()
this.hooksConfig = await loadHooks(workspace)
logs.debug("Hooks configuration loaded", "CLI", {
events: Object.keys(this.hooksConfig),
})

// Set hooks config in store for access from atoms and hooks
this.store.set(setHooksConfigAtom, this.hooksConfig)
this.store.set(setHooksWorkspaceAtom, workspace)

// Apply provider and model overrides from CLI
if (this.options.provider || this.options.model) {
config = await this.applyProviderModelOverrides(config)
Expand Down Expand Up @@ -326,6 +362,19 @@ export class CLI {
await this.resumeLastConversation()
}

// Generate session ID for hooks context
this.sessionId = randomUUID()
this.store.set(setHooksSessionIdAtom, this.sessionId)

// Run SessionStart hooks
const isResume = Boolean(this.options.continue || this.options.session || this.options.fork)
await runSessionStartHooks(this.hooksConfig, {
workspace: this.options.workspace || process.cwd(),
session_id: this.sessionId,
isResume,
})
logs.debug("SessionStart hooks executed", "CLI", { isResume })

this.isInitialized = true
logs.info("Kilo Code CLI initialized successfully", "CLI")
} catch (error) {
Expand Down Expand Up @@ -452,6 +501,18 @@ export class CLI {
try {
logs.info("Disposing Kilo Code CLI...", "CLI")

// Run SessionEnd hooks before cleanup
const exitReason = signal || (this.options.ci ? "ci_exit" : "user_exit")
const sessionEndInput: { workspace: string; reason: string; session_id?: string } = {
workspace: this.options.workspace || process.cwd(),
reason: exitReason,
}
if (this.sessionId) {
sessionEndInput.session_id = this.sessionId
}
await runSessionEndHooks(this.hooksConfig, sessionEndInput)
logs.debug("SessionEnd hooks executed", "CLI", { reason: exitReason })

await this.sessionService?.doSync(true)

// Signal codes take precedence over CI logic
Expand Down
1 change: 1 addition & 0 deletions cli/src/config/__tests__/persistence.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -159,6 +159,7 @@ describe("Config Persistence", () => {
],
autoApproval: DEFAULT_CONFIG.autoApproval,
customThemes: {},
hooks: {},
}

await saveConfig(testConfig)
Expand Down
8 changes: 7 additions & 1 deletion cli/src/config/defaults.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,9 @@
import type { CLIConfig, AutoApprovalConfig } from "./types.js"
import type { CLIConfig, AutoApprovalConfig, HooksConfig } from "./types.js"

/**
* Default hooks configuration (empty - no hooks registered by default)
*/
export const DEFAULT_HOOKS: HooksConfig = {}

/**
* Default auto approval configuration
Expand Down Expand Up @@ -59,6 +64,7 @@ export const DEFAULT_CONFIG = {
},
],
autoApproval: DEFAULT_AUTO_APPROVAL,
hooks: DEFAULT_HOOKS,
theme: "dark",
customThemes: {},
} satisfies CLIConfig
117 changes: 116 additions & 1 deletion cli/src/config/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,124 @@ import type { ProviderConfig as CoreProviderConfig, CLIConfig as CoreCLIConfig }
// ProviderConfig with index signature for dynamic property access (backward compatibility)
export type ProviderConfig = CoreProviderConfig & { [key: string]: unknown }

// CLIConfig with our enhanced ProviderConfig type
// ============================================
// HOOKS TYPES - Claude Code compatible hooks
// ============================================

/**
* Hook event types supported by Kilo CLI
* Matches Claude Code hook events where applicable
*/
export type HookEvent =
| "PreToolUse" // Runs before tool calls (can block them)
| "PostToolUse" // Runs after tool calls complete
| "PermissionRequest" // Runs when a permission dialog is shown (can allow or deny)
| "Notification" // Runs when notifications are sent
| "UserPromptSubmit" // Runs when user submits a prompt, before processing
| "Stop" // Runs when the agent finishes responding
| "PreCompact" // Runs before a compact/condense operation
| "SessionStart" // Runs when a session starts or resumes
| "SessionEnd" // Runs when a session ends

/**
* Individual hook command definition
* Matches Claude Code hook command structure
*/
export interface HookCommand {
/** Type of hook - currently only "command" is supported */
type: "command"
/** Shell command to execute. Receives JSON input on stdin. */
command: string
/** Optional timeout in milliseconds (default: 30000) */
timeout?: number
}

/**
* Hook matcher entry - matches specific tools/patterns for an event
* Matches Claude Code matcher structure
*/
export interface HookMatcher {
/**
* Pattern to match against tool names or other event-specific identifiers
* - Empty string or "*" matches all
* - Pipe-separated values match any (e.g., "Edit|Write")
* - Exact string matches that specific tool/identifier
*/
matcher: string
/** Array of hooks to execute when matcher matches */
hooks: HookCommand[]
}

/**
* Hooks configuration - maps events to matchers
* Matches Claude Code hooks configuration structure
*/
export interface HooksConfig {
PreToolUse?: HookMatcher[]
PostToolUse?: HookMatcher[]
PermissionRequest?: HookMatcher[]
Notification?: HookMatcher[]
UserPromptSubmit?: HookMatcher[]
Stop?: HookMatcher[]
PreCompact?: HookMatcher[]
SessionStart?: HookMatcher[]
SessionEnd?: HookMatcher[]
}

/**
* Hook execution result from a hook command
*/
export interface HookResult {
/** Exit code from the hook command */
exitCode: number
/** Stdout from the hook command */
stdout: string
/** Stderr from the hook command */
stderr: string
/** Parsed JSON decision from stdout (if valid JSON) */
decision?: HookDecision
}

/**
* Hook decision returned via JSON stdout
* Matches Claude Code hook decision structure
*/
export interface HookDecision {
/** Permission decision - "allow" or "deny" */
permissionDecision?: "allow" | "deny"
/** Reason for the decision (shown to user/agent) */
permissionDecisionReason?: string
/** Additional data to pass back */
[key: string]: unknown
}

/**
* Input data passed to hooks via stdin
*/
export interface HookInput {
/** The hook event type */
hook_event: HookEvent
/** Tool name (for tool-related events) */
tool_name?: string
/** Tool input parameters (for PreToolUse) */
tool_input?: Record<string, unknown>
/** Tool output/result (for PostToolUse) */
tool_output?: unknown
/** User prompt text (for UserPromptSubmit) */
prompt?: string
/** Session ID */
session_id?: string
/** Workspace path */
workspace?: string
/** Additional event-specific data */
[key: string]: unknown
}

// CLIConfig with our enhanced ProviderConfig type and hooks support
export interface CLIConfig extends Omit<CoreCLIConfig, "providers"> {
providers: ProviderConfig[]
/** Lifecycle hooks configuration */
hooks?: HooksConfig
}

// Re-export all config types from core-schemas
Expand Down
Loading