feat(KB-643): add project scripts management to dashboard
- Add scripts field to ProjectSettings type for storing custom scripts\n- Add API endpoints for scripts CRUD operations and execution\n- Add client-side API functions for script management\n- Create ScriptsModal component for managing and running scripts\n- Integrate Scripts modal into dashboard header\n- Add comprehensive tests for ScriptsModal component\n- Include changeset documenting the new dashboard scripts UI feature\n- Fix duplicate terminalInitialCommand declaration
This commit is contained in:
@@ -666,6 +666,10 @@ export interface ProjectSettings {
|
|||||||
* Must be set together with `titleSummarizerProvider`. Falls back to planningModelId,
|
* Must be set together with `titleSummarizerProvider`. Falls back to planningModelId,
|
||||||
* then defaultModelId if not specified. */
|
* then defaultModelId if not specified. */
|
||||||
titleSummarizerModelId?: string;
|
titleSummarizerModelId?: string;
|
||||||
|
/** Project-defined shell scripts for quick command execution.
|
||||||
|
* Key is the script name, value is the shell command to execute.
|
||||||
|
* Script names must be alphanumeric with hyphens and underscores only. */
|
||||||
|
scripts?: Record<string, string>;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -731,6 +735,7 @@ export const DEFAULT_PROJECT_SETTINGS: ProjectSettings = {
|
|||||||
autoSummarizeTitles: false,
|
autoSummarizeTitles: false,
|
||||||
titleSummarizerProvider: undefined,
|
titleSummarizerProvider: undefined,
|
||||||
titleSummarizerModelId: undefined,
|
titleSummarizerModelId: undefined,
|
||||||
|
scripts: {},
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -793,6 +798,7 @@ export const PROJECT_SETTINGS_KEYS: ReadonlyArray<keyof ProjectSettings> = [
|
|||||||
"autoSummarizeTitles",
|
"autoSummarizeTitles",
|
||||||
"titleSummarizerProvider",
|
"titleSummarizerProvider",
|
||||||
"titleSummarizerModelId",
|
"titleSummarizerModelId",
|
||||||
|
"scripts",
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
export interface BoardConfig {
|
export interface BoardConfig {
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ import { ActivityLogModal } from "./components/ActivityLogModal";
|
|||||||
import { WorkflowStepManager } from "./components/WorkflowStepManager";
|
import { WorkflowStepManager } from "./components/WorkflowStepManager";
|
||||||
import { AgentListModal } from "./components/AgentListModal";
|
import { AgentListModal } from "./components/AgentListModal";
|
||||||
import { AgentsView } from "./components/AgentsView";
|
import { AgentsView } from "./components/AgentsView";
|
||||||
|
import { ScriptsModal } from "./components/ScriptsModal";
|
||||||
import { useTasks } from "./hooks/useTasks";
|
import { useTasks } from "./hooks/useTasks";
|
||||||
import { useProjects } from "./hooks/useProjects";
|
import { useProjects } from "./hooks/useProjects";
|
||||||
import { useCurrentProject } from "./hooks/useCurrentProject";
|
import { useCurrentProject } from "./hooks/useCurrentProject";
|
||||||
@@ -48,6 +49,8 @@ function AppInner() {
|
|||||||
const [gitManagerOpen, setGitManagerOpen] = useState(false);
|
const [gitManagerOpen, setGitManagerOpen] = useState(false);
|
||||||
const [workflowStepsOpen, setWorkflowStepsOpen] = useState(false);
|
const [workflowStepsOpen, setWorkflowStepsOpen] = useState(false);
|
||||||
const [agentsOpen, setAgentsOpen] = useState(false);
|
const [agentsOpen, setAgentsOpen] = useState(false);
|
||||||
|
const [scriptsOpen, setScriptsOpen] = useState(false);
|
||||||
|
const [terminalInitialCommand, setTerminalInitialCommand] = useState<string | undefined>(undefined);
|
||||||
const [settingsInitialSection, setSettingsInitialSection] = useState<SectionId | undefined>(undefined);
|
const [settingsInitialSection, setSettingsInitialSection] = useState<SectionId | undefined>(undefined);
|
||||||
const [maxConcurrent, setMaxConcurrent] = useState(2);
|
const [maxConcurrent, setMaxConcurrent] = useState(2);
|
||||||
const [rootDir, setRootDir] = useState<string>(".");
|
const [rootDir, setRootDir] = useState<string>(".");
|
||||||
@@ -332,10 +335,6 @@ function AppInner() {
|
|||||||
setTerminalOpen((prev) => !prev);
|
setTerminalOpen((prev) => !prev);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const handleTerminalClose = useCallback(() => {
|
|
||||||
setTerminalOpen(false);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const handleOpenFiles = useCallback(() => {
|
const handleOpenFiles = useCallback(() => {
|
||||||
setFilesOpen(true);
|
setFilesOpen(true);
|
||||||
}, []);
|
}, []);
|
||||||
@@ -361,6 +360,22 @@ function AppInner() {
|
|||||||
const handleOpenAgents = useCallback(() => setAgentsOpen(true), []);
|
const handleOpenAgents = useCallback(() => setAgentsOpen(true), []);
|
||||||
const handleCloseAgents = useCallback(() => setAgentsOpen(false), []);
|
const handleCloseAgents = useCallback(() => setAgentsOpen(false), []);
|
||||||
|
|
||||||
|
// Scripts handlers
|
||||||
|
const handleOpenScripts = useCallback(() => setScriptsOpen(true), []);
|
||||||
|
const handleCloseScripts = useCallback(() => setScriptsOpen(false), []);
|
||||||
|
|
||||||
|
const handleRunScript = useCallback((name: string, command: string) => {
|
||||||
|
setTerminalInitialCommand(command);
|
||||||
|
setScriptsOpen(false);
|
||||||
|
setTerminalOpen(true);
|
||||||
|
addToast(`Running script: ${name}`, "success");
|
||||||
|
}, [addToast]);
|
||||||
|
|
||||||
|
const handleTerminalClose = useCallback(() => {
|
||||||
|
setTerminalOpen(false);
|
||||||
|
setTerminalInitialCommand(undefined);
|
||||||
|
}, []);
|
||||||
|
|
||||||
// Setup wizard complete handler
|
// Setup wizard complete handler
|
||||||
const handleSetupComplete = useCallback((project: ProjectInfo) => {
|
const handleSetupComplete = useCallback((project: ProjectInfo) => {
|
||||||
setSetupWizardOpen(false);
|
setSetupWizardOpen(false);
|
||||||
@@ -450,6 +465,7 @@ function AppInner() {
|
|||||||
onOpenGitManager={handleOpenGitManager}
|
onOpenGitManager={handleOpenGitManager}
|
||||||
onOpenWorkflowSteps={() => setWorkflowStepsOpen(true)}
|
onOpenWorkflowSteps={() => setWorkflowStepsOpen(true)}
|
||||||
onOpenAgents={handleOpenAgents}
|
onOpenAgents={handleOpenAgents}
|
||||||
|
onOpenScripts={handleOpenScripts}
|
||||||
onToggleTerminal={handleToggleTerminal}
|
onToggleTerminal={handleToggleTerminal}
|
||||||
onOpenFiles={handleOpenFiles}
|
onOpenFiles={handleOpenFiles}
|
||||||
filesOpen={filesOpen}
|
filesOpen={filesOpen}
|
||||||
@@ -518,6 +534,13 @@ function AppInner() {
|
|||||||
<TerminalModal
|
<TerminalModal
|
||||||
isOpen={terminalOpen}
|
isOpen={terminalOpen}
|
||||||
onClose={handleTerminalClose}
|
onClose={handleTerminalClose}
|
||||||
|
initialCommand={terminalInitialCommand}
|
||||||
|
/>
|
||||||
|
<ScriptsModal
|
||||||
|
isOpen={scriptsOpen}
|
||||||
|
onClose={handleCloseScripts}
|
||||||
|
addToast={addToast}
|
||||||
|
onRunScript={handleRunScript}
|
||||||
/>
|
/>
|
||||||
{filesOpen && (
|
{filesOpen && (
|
||||||
<FileBrowserModal
|
<FileBrowserModal
|
||||||
|
|||||||
@@ -1910,3 +1910,39 @@ export interface TaskDiff {
|
|||||||
export function fetchTaskDiff(taskId: string): Promise<TaskDiff> {
|
export function fetchTaskDiff(taskId: string): Promise<TaskDiff> {
|
||||||
return api<TaskDiff>(`/tasks/${encodeURIComponent(taskId)}/diff`);
|
return api<TaskDiff>(`/tasks/${encodeURIComponent(taskId)}/diff`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Scripts API ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/** Script execution result */
|
||||||
|
export interface ScriptRunResult {
|
||||||
|
output: string;
|
||||||
|
exitCode: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Fetch all project-defined scripts */
|
||||||
|
export function fetchScripts(): Promise<Record<string, string>> {
|
||||||
|
return api<Record<string, string>>("/scripts");
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Add or update a script */
|
||||||
|
export function addScript(name: string, command: string): Promise<Record<string, string>> {
|
||||||
|
return api<Record<string, string>>("/scripts", {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({ name, command }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Remove a script by name */
|
||||||
|
export function removeScript(name: string): Promise<Record<string, string>> {
|
||||||
|
return api<Record<string, string>>(`/scripts/${encodeURIComponent(name)}`, {
|
||||||
|
method: "DELETE",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Execute a script with optional arguments */
|
||||||
|
export function runScript(name: string, args?: string[]): Promise<ScriptRunResult> {
|
||||||
|
return api<ScriptRunResult>(`/scripts/${encodeURIComponent(name)}/run`, {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({ args }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ export interface HeaderProps {
|
|||||||
onOpenGitManager?: () => void;
|
onOpenGitManager?: () => void;
|
||||||
onOpenWorkflowSteps?: () => void;
|
onOpenWorkflowSteps?: () => void;
|
||||||
onOpenAgents?: () => void;
|
onOpenAgents?: () => void;
|
||||||
|
onOpenScripts?: () => void;
|
||||||
onToggleTerminal?: () => void;
|
onToggleTerminal?: () => void;
|
||||||
/** Opens the top-level workspace-aware file browser modal. */
|
/** Opens the top-level workspace-aware file browser modal. */
|
||||||
onOpenFiles?: () => void;
|
onOpenFiles?: () => void;
|
||||||
@@ -74,6 +75,7 @@ export function Header({
|
|||||||
onOpenGitManager,
|
onOpenGitManager,
|
||||||
onOpenWorkflowSteps,
|
onOpenWorkflowSteps,
|
||||||
onOpenAgents,
|
onOpenAgents,
|
||||||
|
onOpenScripts,
|
||||||
onToggleTerminal,
|
onToggleTerminal,
|
||||||
onOpenFiles,
|
onOpenFiles,
|
||||||
filesOpen,
|
filesOpen,
|
||||||
@@ -393,6 +395,18 @@ export function Header({
|
|||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Scripts - desktop only */}
|
||||||
|
{!isMobile && onOpenScripts && (
|
||||||
|
<button
|
||||||
|
className="btn-icon"
|
||||||
|
onClick={onOpenScripts}
|
||||||
|
title="Scripts"
|
||||||
|
data-testid="scripts-btn"
|
||||||
|
>
|
||||||
|
<Terminal size={16} />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Settings - always inline on desktop */}
|
{/* Settings - always inline on desktop */}
|
||||||
{!isMobile && (
|
{!isMobile && (
|
||||||
<button className="btn-icon" onClick={onOpenSettings} title="Settings">
|
<button className="btn-icon" onClick={onOpenSettings} title="Settings">
|
||||||
@@ -495,6 +509,17 @@ export function Header({
|
|||||||
<span>Manage Agents</span>
|
<span>Manage Agents</span>
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
{onOpenScripts && (
|
||||||
|
<button
|
||||||
|
className="mobile-overflow-item"
|
||||||
|
onClick={() => handleOverflowAction(onOpenScripts)}
|
||||||
|
role="menuitem"
|
||||||
|
data-testid="overflow-scripts-btn"
|
||||||
|
>
|
||||||
|
<Terminal size={16} />
|
||||||
|
<span>Scripts</span>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
<button
|
<button
|
||||||
className="mobile-overflow-item"
|
className="mobile-overflow-item"
|
||||||
onClick={() => handleOverflowAction(onToggleTerminal)}
|
onClick={() => handleOverflowAction(onToggleTerminal)}
|
||||||
|
|||||||
@@ -5789,6 +5789,187 @@ Output ONLY the prompt text (no markdown, no explanations).`;
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ── Scripts Routes ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/scripts
|
||||||
|
* Returns all project-defined scripts from settings.
|
||||||
|
* Response: Record<string, string>
|
||||||
|
*/
|
||||||
|
router.get("/scripts", async (_req, res) => {
|
||||||
|
try {
|
||||||
|
const settings = await store.getSettings();
|
||||||
|
res.json(settings.scripts || {});
|
||||||
|
} catch (err: any) {
|
||||||
|
res.status(500).json({ error: err.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/scripts
|
||||||
|
* Add or update a script.
|
||||||
|
* Body: { name: string, command: string }
|
||||||
|
* Validates name (alphanumeric, hyphens, underscores only, no spaces).
|
||||||
|
* Returns: Record<string, string> (updated scripts)
|
||||||
|
*/
|
||||||
|
router.post("/scripts", async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { name, command } = req.body;
|
||||||
|
|
||||||
|
// Validate name
|
||||||
|
if (!name || typeof name !== "string" || !name.trim()) {
|
||||||
|
res.status(400).json({ error: "name is required" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!command || typeof command !== "string" || !command.trim()) {
|
||||||
|
res.status(400).json({ error: "command is required" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const trimmedName = name.trim();
|
||||||
|
const trimmedCommand = command.trim();
|
||||||
|
|
||||||
|
// Validate script name format (alphanumeric, hyphens, underscores only)
|
||||||
|
if (!/^[a-zA-Z0-9_-]+$/.test(trimmedName)) {
|
||||||
|
res.status(400).json({
|
||||||
|
error: "Script name must be alphanumeric with hyphens and underscores only (no spaces)",
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for reserved/conflicting names
|
||||||
|
const reservedNames = ["run", "list", "add", "remove", "delete", "help"];
|
||||||
|
if (reservedNames.includes(trimmedName.toLowerCase())) {
|
||||||
|
res.status(400).json({ error: `Script name '${trimmedName}' is reserved` });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const settings = await store.getSettings();
|
||||||
|
const currentScripts = settings.scripts || {};
|
||||||
|
|
||||||
|
// Check if script already exists (for conflict detection)
|
||||||
|
const exists = trimmedName in currentScripts;
|
||||||
|
|
||||||
|
// Update scripts
|
||||||
|
const updatedScripts = {
|
||||||
|
...currentScripts,
|
||||||
|
[trimmedName]: trimmedCommand,
|
||||||
|
};
|
||||||
|
|
||||||
|
await store.updateSettings({ scripts: updatedScripts });
|
||||||
|
|
||||||
|
res.status(exists ? 200 : 201).json(updatedScripts);
|
||||||
|
} catch (err: any) {
|
||||||
|
res.status(500).json({ error: err.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DELETE /api/scripts/:name
|
||||||
|
* Remove a script by name.
|
||||||
|
* Returns: Record<string, string> (updated scripts)
|
||||||
|
*/
|
||||||
|
router.delete("/scripts/:name", async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { name } = req.params;
|
||||||
|
|
||||||
|
if (!name || !name.trim()) {
|
||||||
|
res.status(400).json({ error: "Script name is required" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const settings = await store.getSettings();
|
||||||
|
const currentScripts = settings.scripts || {};
|
||||||
|
|
||||||
|
if (!(name in currentScripts)) {
|
||||||
|
res.status(404).json({ error: `Script '${name}' not found` });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove the script
|
||||||
|
const { [name]: _removed, ...remainingScripts } = currentScripts;
|
||||||
|
|
||||||
|
await store.updateSettings({ scripts: remainingScripts });
|
||||||
|
|
||||||
|
res.json(remainingScripts);
|
||||||
|
} catch (err: any) {
|
||||||
|
res.status(500).json({ error: err.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/scripts/:name/run
|
||||||
|
* Execute a script with optional args.
|
||||||
|
* Body: { args?: string[] }
|
||||||
|
* Returns: { output: string; exitCode: number }
|
||||||
|
*/
|
||||||
|
router.post("/scripts/:name/run", async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { name } = req.params;
|
||||||
|
const { args } = req.body;
|
||||||
|
|
||||||
|
if (!name || !name.trim()) {
|
||||||
|
res.status(400).json({ error: "Script name is required" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate args if provided
|
||||||
|
if (args !== undefined && !Array.isArray(args)) {
|
||||||
|
res.status(400).json({ error: "args must be an array of strings" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (args && args.some((arg: unknown) => typeof arg !== "string")) {
|
||||||
|
res.status(400).json({ error: "args must be an array of strings" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const settings = await store.getSettings();
|
||||||
|
const scripts = settings.scripts || {};
|
||||||
|
const command = scripts[name];
|
||||||
|
|
||||||
|
if (!command) {
|
||||||
|
res.status(404).json({ error: `Script '${name}' not found` });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build the full command with args
|
||||||
|
const sanitizedArgs = (args || [])
|
||||||
|
.map((arg: string) => arg.replace(/["\\]/g, "\\$&"))
|
||||||
|
.join(" ");
|
||||||
|
const fullCommand = sanitizedArgs ? `${command} ${sanitizedArgs}` : command;
|
||||||
|
|
||||||
|
// Execute the command using terminal service or execSync
|
||||||
|
const rootDir = store.getRootDir();
|
||||||
|
let output: string;
|
||||||
|
let exitCode: number;
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Use execSync for synchronous execution
|
||||||
|
output = execSync(fullCommand, {
|
||||||
|
encoding: "utf-8",
|
||||||
|
timeout: 300000, // 5 minute timeout
|
||||||
|
cwd: rootDir,
|
||||||
|
stdio: ["pipe", "pipe", "pipe"],
|
||||||
|
});
|
||||||
|
exitCode = 0;
|
||||||
|
} catch (execErr: any) {
|
||||||
|
// Command failed or timed out
|
||||||
|
output = execErr.stdout || "";
|
||||||
|
if (execErr.stderr) {
|
||||||
|
output += (output ? "\n" : "") + execErr.stderr;
|
||||||
|
}
|
||||||
|
if (execErr.message && !execErr.stderr) {
|
||||||
|
output += (output ? "\n" : "") + execErr.message;
|
||||||
|
}
|
||||||
|
exitCode = execErr.status || 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({ output: output.trim(), exitCode });
|
||||||
|
} catch (err: any) {
|
||||||
|
res.status(500).json({ error: err.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// ── Agent Routes ───────────────────────────────────────────────────────────
|
// ── Agent Routes ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
Reference in New Issue
Block a user