Compare commits

...

4 Commits

Author SHA1 Message Date
Saoud Rizwan fb1aa7cc4d fix(acp): forward cli config and cwd to agent 2026-03-05 23:25:30 -08:00
Saoud Rizwan 0be10f6cfd fix(cli): initialize runtime hooks before interactive startup 2026-03-05 23:25:12 -08:00
Saoud Rizwan 5a275fa474 Merge branch 'main' into saoudrizwan/cli-hooks-dir 2026-03-05 19:48:27 -08:00
Saoud Rizwan 0d9a097e90 feat(cli): add --hooks-dir flag for runtime hook injection
Adds a --hooks-dir <path> CLI flag that allows passing an additional
hooks directory at spawn time. This enables orchestration tools (like
Kanbanana) to inject per-session lifecycle hooks without mutating
the user's global or workspace hooks directories.

The runtime hooks directory is included alongside existing global
(~/Documents/Cline/Hooks/) and workspace (.clinerules/hooks/)
directories during hook discovery. All hooks from all directories
are merged and run in parallel, so runtime hooks are purely additive.
2026-03-03 22:05:17 -08:00
7 changed files with 99 additions and 3 deletions
+5
View File
@@ -69,6 +69,8 @@ export interface AcpModeOptions {
config?: string
/** Working directory (default: process.cwd()) */
cwd?: string
/** Additional runtime hooks directory */
hooksDir?: string
/** Enable verbose/debug logging to stderr */
verbose?: boolean
}
@@ -95,7 +97,10 @@ export async function runAcpMode(options: AcpModeOptions = {}): Promise<void> {
new AgentSideConnection((conn) => {
agent = new AcpAgent(conn, {
clineDir: options.config,
cwd: options.cwd,
debug: Boolean(options.verbose),
hooksDir: options.hooksDir,
})
return agent
}, stream)
+6 -1
View File
@@ -42,6 +42,7 @@ import { getProviderModelIdKey } from "@shared/storage/provider-keys"
import { ClineEndpoint } from "@/config.js"
import { Controller } from "@/core/controller"
import { getAvailableSlashCommands } from "@/core/controller/slash/getAvailableSlashCommands"
import { setRuntimeHooksDir } from "@/core/storage/disk"
import { StateManager } from "@/core/storage/StateManager"
import { AuthHandler } from "@/hosts/external/AuthHandler.js"
import { ExternalCommentReviewController } from "@/hosts/external/ExternalCommentReviewController.js"
@@ -140,7 +141,11 @@ export class ClineAgent implements acp.Agent {
constructor(options: ClineAgentOptions) {
this.options = options
this.ctx = initializeCliContext({ clineDir: options.clineDir })
setRuntimeHooksDir(options.hooksDir)
this.ctx = initializeCliContext({
clineDir: options.clineDir,
workspaceDir: options.cwd,
})
}
/**
+10
View File
@@ -71,6 +71,10 @@ export interface ClineAgentOptions {
debug?: boolean
/** Cline Config Directory (defaults to ~/.cline) */
clineDir?: string
/** Working directory for storage and workspace context */
cwd?: string
/** Additional runtime hooks directory */
hooksDir?: string
}
/**
@@ -79,6 +83,12 @@ export interface ClineAgentOptions {
export interface AcpAgentOptions {
/** Whether debug logging is enabled */
debug?: boolean
/** Cline Config Directory (defaults to ~/.cline) */
clineDir?: string
/** Working directory for storage and workspace context */
cwd?: string
/** Additional runtime hooks directory */
hooksDir?: string
}
// ============================================================
+14
View File
@@ -33,6 +33,7 @@ describe("CLI Commands", () => {
.option("--thinking [tokens]", "Enable extended thinking")
.option("--reasoning-effort <effort>", "Reasoning effort")
.option("--max-consecutive-mistakes <count>", "Maximum consecutive mistakes")
.option("--hooks-dir <path>", "Additional hooks directory")
.action(() => {})
program
@@ -72,6 +73,7 @@ describe("CLI Commands", () => {
.option("--thinking [tokens]", "Enable extended thinking")
.option("--reasoning-effort <effort>", "Reasoning effort")
.option("--max-consecutive-mistakes <count>", "Maximum consecutive mistakes")
.option("--hooks-dir <path>", "Additional hooks directory")
.action(() => {})
})
@@ -171,6 +173,13 @@ describe("CLI Commands", () => {
expect(taskCmd.opts().maxConsecutiveMistakes).toBe("999")
})
it("should parse --hooks-dir option", () => {
const taskCmd = program.commands.find((c) => c.name() === "task")!
const args = ["test prompt", "--hooks-dir", "/tmp/hooks"]
taskCmd.parse(args, { from: "user" })
expect(taskCmd.opts().hooksDir).toBe("/tmp/hooks")
})
it("should parse short flags", () => {
const taskCmd = program.commands.find((c) => c.name() === "task")!
const args = ["test prompt", "-a", "-v", "-m", "gpt-4"]
@@ -321,6 +330,11 @@ describe("CLI Commands", () => {
program.parse(["node", "cli", "--max-consecutive-mistakes", "7"])
expect(program.opts().maxConsecutiveMistakes).toBe("7")
})
it("should parse --hooks-dir option", () => {
program.parse(["node", "cli", "--hooks-dir", "/tmp/hooks"])
expect(program.opts().hooksDir).toBe("/tmp/hooks")
})
})
describe("command structure", () => {
+7
View File
@@ -9,6 +9,7 @@ import { render } from "ink"
import React from "react"
import { ClineEndpoint } from "@/config"
import type { Controller } from "@/core/controller"
import { setRuntimeHooksDir } from "@/core/storage/disk"
import { StateManager } from "@/core/storage/StateManager"
import { AuthHandler } from "@/hosts/external/AuthHandler"
import { HostProvider } from "@/hosts/host-provider"
@@ -65,6 +66,7 @@ interface TaskOptions {
timeout?: string
json?: boolean
stdinWasPiped?: boolean
hooksDir?: string
}
let telemetryDisposed = false
@@ -394,6 +396,7 @@ interface CliContext {
interface InitOptions {
config?: string
cwd?: string
hooksDir?: string
verbose?: boolean
enableAuth?: boolean
}
@@ -403,6 +406,7 @@ interface InitOptions {
*/
async function initializeCli(options: InitOptions): Promise<CliContext> {
const workspacePath = options.cwd || process.cwd()
setRuntimeHooksDir(options.hooksDir)
const { extensionContext, storageContext, DATA_DIR, EXTENSION_DIR } = initializeCliContext({
clineDir: options.config,
workspaceDir: workspacePath,
@@ -738,6 +742,7 @@ program
.option("--max-consecutive-mistakes <count>", "Maximum consecutive mistakes before halting in yolo mode")
.option("--json", "Output messages as JSON instead of styled text")
.option("--double-check-completion", "Reject first completion attempt to force re-verification")
.option("--hooks-dir <path>", "Path to additional hooks directory for runtime hook injection")
.option("-T, --taskId <id>", "Resume an existing task by ID")
.action((prompt, options) => {
if (options.taskId) {
@@ -905,6 +910,7 @@ program
.option("--max-consecutive-mistakes <count>", "Maximum consecutive mistakes before halting in yolo mode")
.option("--json", "Output messages as JSON instead of styled text")
.option("--double-check-completion", "Reject first completion attempt to force re-verification")
.option("--hooks-dir <path>", "Path to additional hooks directory for runtime hook injection")
.option("--acp", "Run in ACP (Agent Client Protocol) mode for editor integration")
.option("-T, --taskId <id>", "Resume an existing task by ID")
.action(async (prompt, options) => {
@@ -913,6 +919,7 @@ program
await runAcpMode({
config: options.config,
cwd: options.cwd,
hooksDir: options.hooksDir,
verbose: options.verbose,
})
return
+38
View File
@@ -10,9 +10,11 @@ import { HostProvider } from "@/hosts/host-provider"
import { setVscodeHostProviderMock } from "@/test/host-provider-test-utils"
import {
ensureStateDirectoryExists,
getAllHooksDirs,
getTaskHistoryStateFilePath,
getWorkspaceHooksDirs,
readTaskHistoryFromState,
setRuntimeHooksDir,
writeTaskHistoryToState,
} from "../disk"
import { StateManager } from "../StateManager"
@@ -29,6 +31,7 @@ describe("disk - hooks functionality", () => {
afterEach(async () => {
sandbox.restore()
setRuntimeHooksDir(undefined)
try {
await fs.rm(tempDir, { recursive: true, force: true })
} catch (error) {
@@ -199,6 +202,41 @@ describe("disk - hooks functionality", () => {
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)
sandbox.stub(fsUtils, "isDirectory").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)
sandbox.stub(fsUtils, "isDirectory").resolves(false)
setRuntimeHooksDir(runtimeHooksDir)
const result = await getAllHooksDirs()
result.should.not.containEql(runtimeHooksDir)
})
})
})
describe("disk - atomic writes", () => {
+19 -2
View File
@@ -508,10 +508,22 @@ export async function getGlobalHooksDir(): Promise<string | undefined> {
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 global hooks directory (if it exists)
* 2. Each workspace root's .clinerules/hooks directory (if they exist)
* 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.
@@ -521,6 +533,11 @@ export async function getGlobalHooksDir(): Promise<string | undefined> {
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) {