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
28 changes: 1 addition & 27 deletions packages/web/src/components/board/agent-control.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ import { useAtomValue } from "jotai";
import { toast } from "sonner";
import { apiClient } from "@/lib/api/client";
import { sseConnectedAtom } from "@/lib/atoms/connection";
import { cn } from "@/lib/utils";
import { cn, asBool, normalizeLauncher, quoteShell } from "@/lib/utils";
import {
openInEditor,
launcherDisplayName,
Expand All @@ -28,32 +28,6 @@ import {
} from "@/components/ui/dialog";
import { Switch } from "@/components/ui/switch";

const LAUNCHER_BACKENDS: readonly LauncherBackend[] = [
"tmux",
"nvim",
"vscode",
"cursor",
"windsurf",
"kiro",
"antigravity",
];

function asBool(value: string | undefined, fallback: boolean): boolean {
if (value === undefined) return fallback;
return !["0", "false", "no", "off"].includes(value.trim().toLowerCase());
}

function normalizeLauncher(value: string | null | undefined): LauncherBackend {
if (!value) return "vscode";
const normalized = value.trim().toLowerCase();
return LAUNCHER_BACKENDS.includes(normalized as LauncherBackend)
? (normalized as LauncherBackend)
: "vscode";
}

function quoteShell(value: string): string {
return `"${value.replace(/["\\$`]/g, "\\$&")}"`;
}

function tmuxSessionName(sessionId: string): string {
return `kagan-${sessionId.replaceAll(":", "-")}`;
Expand Down
6 changes: 1 addition & 5 deletions packages/web/src/components/settings/settings-panel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import {
FieldSeparator,
FieldSet,
} from '@/components/ui/field';
import { asBool } from '@/lib/utils';
import { AppearanceSettings } from './sections/appearance-settings';
import { WorkflowSettings } from './sections/workflow-settings';
import { WorkspaceSettings } from './sections/workspace-settings';
Expand Down Expand Up @@ -71,11 +72,6 @@ export const DEFAULT_FORM: SettingsFormState = {
auto_confirm_single_tasks: false,
};

export function asBool(value: string | undefined, fallback: boolean): boolean {
if (value === undefined) return fallback;
return !['0', 'false', 'no', 'off'].includes(value.trim().toLowerCase());
}

export interface ToggleRowProps {
title: string;
description: string;
Expand Down
87 changes: 87 additions & 0 deletions packages/web/src/lib/__tests__/utils.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
import { describe, expect, it } from "vitest";
import { asBool, normalizeLauncher, quoteShell } from "../utils";

describe("asBool", () => {
it("returns fallback for undefined", () => {
expect(asBool(undefined, true)).toBe(true);
expect(asBool(undefined, false)).toBe(false);
});

it("parses true values", () => {
expect(asBool("true", false)).toBe(true);
expect(asBool("1", false)).toBe(true);
expect(asBool("yes", false)).toBe(true);
expect(asBool("on", false)).toBe(true);
});

it("returns false for falsy strings", () => {
expect(asBool("false", true)).toBe(false);
expect(asBool("0", true)).toBe(false);
expect(asBool("no", true)).toBe(false);
expect(asBool("off", true)).toBe(false);
});

it("is case-insensitive and trims whitespace", () => {
expect(asBool("FALSE", true)).toBe(false);
expect(asBool(" False ", true)).toBe(false);
expect(asBool(" NO ", true)).toBe(false);
});

it("treats unrecognized strings as truthy", () => {
expect(asBool("", false)).toBe(true);
expect(asBool("anything", false)).toBe(true);
});
});

describe("normalizeLauncher", () => {
it("returns vscode for null/undefined/empty", () => {
expect(normalizeLauncher(null)).toBe("vscode");
expect(normalizeLauncher(undefined)).toBe("vscode");
expect(normalizeLauncher("")).toBe("vscode");
});

it("normalizes known backends", () => {
expect(normalizeLauncher("tmux")).toBe("tmux");
expect(normalizeLauncher("nvim")).toBe("nvim");
expect(normalizeLauncher("cursor")).toBe("cursor");
expect(normalizeLauncher("windsurf")).toBe("windsurf");
expect(normalizeLauncher("kiro")).toBe("kiro");
expect(normalizeLauncher("antigravity")).toBe("antigravity");
});

it("is case-insensitive and trims whitespace", () => {
expect(normalizeLauncher("VSCODE")).toBe("vscode");
expect(normalizeLauncher(" Cursor ")).toBe("cursor");
});

it("falls back to vscode for unknown backends", () => {
expect(normalizeLauncher("emacs")).toBe("vscode");
expect(normalizeLauncher("sublime")).toBe("vscode");
});
});

describe("quoteShell", () => {
it("wraps value in double quotes", () => {
expect(quoteShell("hello")).toBe('"hello"');
});

it("escapes double quotes", () => {
expect(quoteShell('say "hi"')).toBe('"say \\"hi\\""');
});

it("escapes backslashes", () => {
expect(quoteShell("path\\to")).toBe('"path\\\\to"');
});

it("escapes dollar signs", () => {
expect(quoteShell("$HOME")).toBe('"\\$HOME"');
});

it("escapes backticks", () => {
expect(quoteShell("`cmd`")).toBe('"\\`cmd\\`"');
});

it("leaves safe characters untouched", () => {
expect(quoteShell("/usr/local/bin")).toBe('"/usr/local/bin"');
});
});
28 changes: 28 additions & 0 deletions packages/web/src/lib/utils.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,34 @@
import { clsx, type ClassValue } from 'clsx';
import { twMerge } from 'tailwind-merge';
import type { LauncherBackend } from '@/lib/utils/editor-links';

export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}

export const LAUNCHER_BACKENDS: readonly LauncherBackend[] = [
'tmux',
'nvim',
'vscode',
'cursor',
'windsurf',
'kiro',
'antigravity',
];

export function asBool(value: string | undefined, fallback: boolean): boolean {
if (value === undefined) return fallback;
return !['0', 'false', 'no', 'off'].includes(value.trim().toLowerCase());
}

export function normalizeLauncher(value: string | null | undefined): LauncherBackend {
if (!value) return 'vscode';
const normalized = value.trim().toLowerCase();
return LAUNCHER_BACKENDS.includes(normalized as LauncherBackend)
? (normalized as LauncherBackend)
: 'vscode';
}

export function quoteShell(value: string): string {
return `"${value.replace(/["\\$`]/g, '\\$&')}"`;
}
23 changes: 1 addition & 22 deletions packages/web/src/pages/task-detail-page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,6 @@ import { useTaskEvents } from "@/lib/hooks/use-task-events";
import {
openInEditor,
launcherDisplayName,
type LauncherBackend,
} from "@/lib/utils/editor-links";
import {
ActionEmptyState,
Expand Down Expand Up @@ -62,30 +61,10 @@ import { EditTaskDialog } from "@/components/board/edit-task-dialog";
import { TaskDeleteDialog } from "@/components/board/task-delete-dialog";
import { TaskSidebar } from "@/components/board/task-sidebar";
import { isEditableTarget, hasOpenOverlay } from "@/lib/utils/dom";
import { normalizeLauncher, quoteShell } from "@/lib/utils";

export type WorkspaceTab = "overview" | "changes" | "review";

const LAUNCHER_BACKENDS: readonly LauncherBackend[] = [
"tmux",
"nvim",
"vscode",
"cursor",
"windsurf",
"kiro",
"antigravity",
];

function normalizeLauncher(value: string | null | undefined): LauncherBackend {
if (!value) return "vscode";
const normalized = value.trim().toLowerCase();
return LAUNCHER_BACKENDS.includes(normalized as LauncherBackend)
? (normalized as LauncherBackend)
: "vscode";
}

function quoteShell(value: string): string {
return `"${value.replace(/["\\$`]/g, "\\$&")}"`;
}

function tmuxSessionName(sessionId: string): string {
return `kagan-${sessionId.replaceAll(":", "-")}`;
Expand Down
6 changes: 4 additions & 2 deletions src/kagan/mcp/toolsets/sessions.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
"""kagan.mcp.toolsets.sessions — Session lifecycle MCP tools."""

import contextlib
from enum import StrEnum
from typing import Any, TypedDict, cast

from loguru import logger
from mcp.server.fastmcp import Context, FastMCP

from kagan.core import resolve_default_agent_backend, resolve_launcher
Expand Down Expand Up @@ -172,8 +172,10 @@ async def _run_summary(ctx: Context, task_ids: list[str] | None = None) -> dict:
for task in tasks:
session = await _get_latest_session(app.client, task.id)
ws = None
with contextlib.suppress(Exception):
try:
ws = await app.client.worktrees.get(task.id)
except (KaganError, OSError) as exc:
logger.debug("Failed to fetch worktree for task {}: {}", task.id, exc)
row: dict[str, Any] = {
"task_id": task.id,
"status": task.status.value,
Expand Down
5 changes: 4 additions & 1 deletion src/kagan/mcp/toolsets/tasks.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
import json
from typing import Any, TypedDict

from loguru import logger
from mcp.server.fastmcp import Context, FastMCP

from kagan.core import Priority, TaskStatus, parse_priority
Expand Down Expand Up @@ -145,10 +146,12 @@ async def _task_get(ctx: Context, task_id: str | None = None) -> dict:
result["session_id"] = app.bound_session_id

# Include worktree path so agents know where task files live
with contextlib.suppress(Exception):
try:
ws = await app.client.worktrees.get(resolved_task_id)
if ws is not None:
result["worktree_path"] = ws.worktree_path
except (KaganError, OSError) as exc:
logger.debug("Failed to fetch worktree for task {}: {}", resolved_task_id, exc)

try:
all_tasks = await app.client.tasks.list()
Expand Down
12 changes: 12 additions & 0 deletions src/kagan/server/_helpers.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
from typing import TYPE_CHECKING, Any, cast

from loguru import logger
from pydantic import BaseModel, ValidationError
from starlette.responses import JSONResponse

from kagan.core import TaskStatus
Expand Down Expand Up @@ -33,6 +34,14 @@ def _err(msg: str, status: int = 400, *, error_code: str | None = None) -> JSONR
return JSONResponse(payload, status_code=status)


async def parse_body[T: BaseModel](request: Any, model: type[T]) -> T:
"""Parse and validate a JSON request body against a Pydantic model."""
payload = await request.json()
if not isinstance(payload, dict):
raise ValueError("Request body must be a JSON object")
return model.model_validate(payload)


def _error_response(exc: Exception) -> JSONResponse:
error_code = cast("str | None", getattr(exc, "code", None))
if isinstance(exc, NotFoundError):
Expand All @@ -48,6 +57,9 @@ def _error_response(exc: Exception) -> JSONResponse:
logger.debug("Route handler missing field: {}", exc)
field = exc.args[0] if exc.args else "unknown"
return _err(f"Missing field: {field}", status=400, error_code=error_code)
if isinstance(exc, ValidationError):
logger.debug("Route handler validation error: {}", exc)
return _err(str(exc), status=422, error_code=error_code)
if isinstance(exc, ValueError | TypeError):
logger.debug("Route handler validation error: {}", exc)
return _err(str(exc), status=400, error_code=error_code)
Expand Down
Loading
Loading