Skip to content
Merged
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
22 changes: 22 additions & 0 deletions codeframe/cli/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@
from codeframe.cli.pr_commands import pr_app
from codeframe.cli.env_commands import env_app
from codeframe.cli.engines_commands import engines_app
from codeframe.cli.hooks_commands import hooks_app

# Load environment variables from .env files
# Priority: workspace .env > home .env
Expand Down Expand Up @@ -148,6 +149,26 @@ def init(
console.print(f" State: {workspace.state_dir}")
if workspace.tech_stack:
console.print(f" Tech Stack: {workspace.tech_stack}")

# Execute after_init hook (non-blocking, only on fresh init)
from codeframe.core.config import load_environment_config
from codeframe.core.hooks import HookContext, execute_hook
env_config = load_environment_config(repo_path)
if env_config and not already_existed:
hook_ctx = HookContext(
task_id="", task_title="", task_status="init",
workspace_path=str(repo_path),
)
hook_result = execute_hook(
"after_init", env_config, repo_path, hook_ctx,
abort_on_failure=False,
)
if hook_result:
if hook_result.success:
console.print(f" Hook after_init: [green]OK[/green] ({hook_result.duration_ms}ms)")
else:
console.print(f" Hook after_init: [yellow]failed[/yellow] ({hook_result.stderr[:100]})")

console.print()
console.print("Next steps:")
console.print(" codeframe prd add <file.md> Add a PRD")
Expand Down Expand Up @@ -4848,6 +4869,7 @@ def templates_apply(
app.add_typer(env_app, name="env")

app.add_typer(engines_app, name="engines")
app.add_typer(hooks_app, name="hooks")


# =============================================================================
Expand Down
208 changes: 208 additions & 0 deletions codeframe/cli/hooks_commands.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,208 @@
"""CLI workspace lifecycle hooks management.

Usage:
codeframe hooks show # Display configured hooks
codeframe hooks run <hook_name> # Manually trigger a hook
codeframe hooks set <name> <cmd> # Set a hook command
codeframe hooks clear <name> # Remove a hook
"""

from pathlib import Path
from typing import Optional

import typer
from rich.console import Console
from rich.table import Table

console = Console()

hooks_app = typer.Typer(
name="hooks",
help="Workspace lifecycle hooks management",
no_args_is_help=True,
)

VALID_HOOK_NAMES = [
"after_init",
"before_task",
"after_task_success",
"after_task_failure",
"before_remove",
]


@hooks_app.command("show")
def hooks_show(
workspace_path: Optional[Path] = typer.Option(
None, "--workspace", "-w",
help="Workspace path (defaults to current directory)",
),
) -> None:
"""Display configured hooks from .codeframe/config.yaml."""
from codeframe.core.config import load_environment_config

from codeframe.core.workspace import get_workspace
path = workspace_path or Path.cwd()
try:
ws = get_workspace(path)
path = ws.repo_path
except (FileNotFoundError, ValueError):
pass # Workspace not initialized; fall back to raw path
config = load_environment_config(path)
Comment thread
coderabbitai[bot] marked this conversation as resolved.

if not config:
console.print("[yellow]No workspace configuration found.[/yellow]")
console.print("Run 'codeframe init .' first.")
raise typer.Exit(1)

table = Table(title="Workspace Hooks")
table.add_column("Hook Point", style="cyan")
table.add_column("Command", style="dim")
table.add_column("Status")

for hook_name in VALID_HOOK_NAMES:
command = getattr(config.hooks, hook_name, None)
if command:
table.add_row(hook_name, command, "[green]configured[/green]")
else:
table.add_row(hook_name, "-", "[dim]not set[/dim]")

table.add_row("", "", "")
table.add_row("hook_timeout", f"{config.hooks.hook_timeout}s", "[dim]default[/dim]")

console.print(table)


@hooks_app.command("run")
def hooks_run(
hook_name: str = typer.Argument(..., help="Hook name to execute"),
task_id: str = typer.Option("", "--task-id", help="Task ID for template rendering"),
task_title: str = typer.Option("", "--task-title", help="Task title for template rendering"),
workspace_path: Optional[Path] = typer.Option(
None, "--workspace", "-w",
help="Workspace path (defaults to current directory)",
),
) -> None:
"""Manually trigger a named hook."""
from codeframe.core.config import load_environment_config
from codeframe.core.hooks import HookContext, execute_hook

if hook_name not in VALID_HOOK_NAMES:
console.print(f"[red]Error:[/red] Invalid hook name '{hook_name}'")
console.print(f"Valid hooks: {', '.join(VALID_HOOK_NAMES)}")
raise typer.Exit(1)

from codeframe.core.workspace import get_workspace
path = workspace_path or Path.cwd()
try:
ws = get_workspace(path)
path = ws.repo_path
except (FileNotFoundError, ValueError):
pass # Workspace not initialized; fall back to raw path
config = load_environment_config(path)

if not config:
console.print("[yellow]No workspace configuration found.[/yellow]")
raise typer.Exit(1)

ctx = HookContext(
task_id=task_id,
task_title=task_title,
task_status="manual",
workspace_path=str(path),
)

result = execute_hook(hook_name, config, path, ctx, abort_on_failure=False)

if result is None:
console.print(f"[yellow]Hook '{hook_name}' is not configured.[/yellow]")
return

if result.success:
console.print(f"[green]Hook '{hook_name}' succeeded[/green] ({result.duration_ms}ms)")
else:
console.print(f"[red]Hook '{hook_name}' failed[/red] ({result.duration_ms}ms)")
if result.timed_out:
console.print(" [yellow]Timed out[/yellow]")

if result.stdout.strip():
console.print(f" stdout: {result.stdout.strip()[:500]}")
if result.stderr.strip():
console.print(f" stderr: {result.stderr.strip()[:500]}")
Comment thread
coderabbitai[bot] marked this conversation as resolved.

if not result.success:
raise typer.Exit(1)


@hooks_app.command("set")
def hooks_set(
hook_name: str = typer.Argument(..., help="Hook name to configure"),
command: str = typer.Argument(..., help="Shell command template"),
workspace_path: Optional[Path] = typer.Option(
None, "--workspace", "-w",
help="Workspace path (defaults to current directory)",
),
) -> None:
"""Set or update a hook command."""
from codeframe.core.config import (
load_environment_config,
save_environment_config,
get_default_environment_config,
)

if hook_name not in VALID_HOOK_NAMES:
console.print(f"[red]Error:[/red] Invalid hook name '{hook_name}'")
console.print(f"Valid hooks: {', '.join(VALID_HOOK_NAMES)}")
raise typer.Exit(1)

from codeframe.core.workspace import get_workspace
path = workspace_path or Path.cwd()
try:
ws = get_workspace(path)
path = ws.repo_path
except (FileNotFoundError, ValueError):
pass # Workspace not initialized; fall back to raw path
config = load_environment_config(path) or get_default_environment_config()

setattr(config.hooks, hook_name, command)
save_environment_config(path, config)

console.print(f"[green]Hook '{hook_name}' set to:[/green] {command}")


@hooks_app.command("clear")
def hooks_clear(
hook_name: str = typer.Argument(..., help="Hook name to clear"),
workspace_path: Optional[Path] = typer.Option(
None, "--workspace", "-w",
help="Workspace path (defaults to current directory)",
),
) -> None:
"""Remove a hook."""
from codeframe.core.config import (
load_environment_config,
save_environment_config,
)

if hook_name not in VALID_HOOK_NAMES:
console.print(f"[red]Error:[/red] Invalid hook name '{hook_name}'")
console.print(f"Valid hooks: {', '.join(VALID_HOOK_NAMES)}")
raise typer.Exit(1)

from codeframe.core.workspace import get_workspace
path = workspace_path or Path.cwd()
try:
ws = get_workspace(path)
path = ws.repo_path
except (FileNotFoundError, ValueError):
pass # Workspace not initialized; fall back to raw path
config = load_environment_config(path)

if not config:
console.print("[yellow]No workspace configuration found.[/yellow]")
raise typer.Exit(1)

setattr(config.hooks, hook_name, None)
save_environment_config(path, config)

console.print(f"[green]Hook '{hook_name}' cleared.[/green]")
21 changes: 21 additions & 0 deletions codeframe/core/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,22 @@ class AgentBudgetConfig:
stall_timeout_s: int = 300


@dataclass
class HooksConfig:
"""Workspace lifecycle hooks configuration.

Hooks are shell commands executed at specific lifecycle points.
Template variables (e.g., {{task_id}}) are rendered via Jinja2.
"""

after_init: Optional[str] = None
before_task: Optional[str] = None
after_task_success: Optional[str] = None
after_task_failure: Optional[str] = None
before_remove: Optional[str] = None
hook_timeout: int = 60
Comment on lines +84 to +97

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Validate hook_timeout when loading config.

HooksConfig currently accepts 0 or negative values, so a bad YAML value is only discovered later when hooks run. This should be rejected up front with the rest of the environment config.

🛠️ Suggested fix
 `@dataclass`
 class HooksConfig:
@@
     after_task_failure: Optional[str] = None
     before_remove: Optional[str] = None
     hook_timeout: int = 60
+
+    def __post_init__(self) -> None:
+        if self.hook_timeout <= 0:
+            raise ValueError("hooks.hook_timeout must be > 0")
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@codeframe/core/config.py` around lines 84 - 97, Add validation to HooksConfig
to reject non-positive timeouts at config load time: implement a __post_init__
method on the HooksConfig dataclass that checks if hook_timeout is an int > 0
and raises a ValueError (or ConfigError if the project has one) with a clear
message when hook_timeout <= 0, so invalid YAML values are surfaced immediately
when HooksConfig is instantiated.



@dataclass
class EnvironmentConfig:
"""v2 project environment configuration.
Expand Down Expand Up @@ -108,6 +124,9 @@ class EnvironmentConfig:
# Agent budget
agent_budget: AgentBudgetConfig = dataclass_field(default_factory=AgentBudgetConfig)

# Workspace lifecycle hooks
hooks: HooksConfig = dataclass_field(default_factory=HooksConfig)

# Execution engine
engine: str = "react"

Expand Down Expand Up @@ -259,6 +278,8 @@ def from_dict(cls, data: dict[str, Any]) -> "EnvironmentConfig":
data["context"] = ContextConfig(**data["context"])
if "agent_budget" in data and isinstance(data["agent_budget"], dict):
data["agent_budget"] = AgentBudgetConfig(**data["agent_budget"])
if "hooks" in data and isinstance(data["hooks"], dict):
data["hooks"] = HooksConfig(**data["hooks"])
return cls(**data)


Expand Down
4 changes: 4 additions & 0 deletions codeframe/core/events.py
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,10 @@ class EventType:
BATCH_CANCELLED = "BATCH_CANCELLED"
BATCH_VALIDATION_FAILED = "BATCH_VALIDATION_FAILED"

# Hook events
HOOK_EXECUTED = "HOOK_EXECUTED"
HOOK_FAILED = "HOOK_FAILED"


@dataclass
class Event:
Expand Down
Loading
Loading