From 4b139045cc53ec511a7d83fd4fd6f0d4ebc596f8 Mon Sep 17 00:00:00 2001 From: Test Date: Tue, 4 Nov 2025 17:41:13 -0500 Subject: [PATCH] =?UTF-8?q?=F0=9F=A4=96=20feat:=20add=20workspace=20script?= =?UTF-8?q?s=20with=20discovery=20and=20execution?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add complete workspace scripts feature with runtime-aware discovery, execution, and auto-completion for both local and SSH workspaces. **Script Discovery:** - New listScripts() function uses Runtime interface instead of local fs - Works with both local and SSH workspaces via execBuffered() - Extracts descriptions from # Description: or # @description comments - Adds WORKSPACE_LIST_SCRIPTS IPC handler - Includes unit tests with mocked runtime **Script Execution:** - New /script and /s slash commands with tab completion - WORKSPACE_EXECUTE_SCRIPT IPC handler using bash tool - Runtime-aware script existence checking via runtime.stat() - Scripts run in workspace directory with project secrets - 5-minute default timeout **Environment Variables:** - CMUX_OUTPUT: Write markdown for custom toast display - CMUX_PROMPT: Send follow-up message to agent after script runs **UI/UX:** - Script execution shows toast with exit code - Custom toast content from CMUX_OUTPUT (10KB limit) - Auto-send CMUX_PROMPT content as user message (100KB limit) - Command palette integration for script selection - Tab completion in chat input **Documentation:** - Add docs/scripts.md with usage examples - Demo scripts in .cmux/scripts/ - Storybook story for script execution flow Generated with cmux Change-Id: I301cff2ec5551b4b1a08d41be84c363dfbf13f72 Signed-off-by: Test --- .cmux/scripts/demo | 28 ++ .cmux/scripts/echo | 44 +++ docs/SUMMARY.md | 1 + docs/scripts.md | 280 +++++++++++++++++++ src/App.stories.tsx | 31 ++ src/App.tsx | 46 ++- src/browser/api.ts | 3 + src/components/ChatInput.tsx | 119 +++++++- src/components/ChatInputToast.tsx | 20 +- src/components/ChatInputToasts.tsx | 26 ++ src/components/CommandPalette.tsx | 13 +- src/constants/ipc-constants.ts | 2 + src/preload.ts | 4 + src/services/ipcMain.ts | 199 +++++++++++++ src/services/tools/bash.ts | 5 +- src/styles/globals.css | 12 +- src/types/ipc.ts | 15 + src/types/tools.ts | 4 + src/utils/scripts/discovery.test.ts | 181 ++++++++++++ src/utils/scripts/discovery.ts | 175 ++++++++++++ src/utils/slashCommands/registry.ts | 46 +++ src/utils/slashCommands/types.ts | 3 + src/utils/tools/tools.ts | 2 + tests/ipcMain/runtimeScriptExecution.test.ts | 126 +++++++++ vite.config.ts | 5 +- 25 files changed, 1363 insertions(+), 27 deletions(-) create mode 100755 .cmux/scripts/demo create mode 100755 .cmux/scripts/echo create mode 100644 docs/scripts.md create mode 100644 src/utils/scripts/discovery.test.ts create mode 100644 src/utils/scripts/discovery.ts create mode 100644 tests/ipcMain/runtimeScriptExecution.test.ts diff --git a/.cmux/scripts/demo b/.cmux/scripts/demo new file mode 100755 index 000000000..1c647750a --- /dev/null +++ b/.cmux/scripts/demo @@ -0,0 +1,28 @@ +#!/usr/bin/env bash +# Description: Demo script to showcase the script execution feature +set -euo pipefail + +# Regular output goes to stdout (visible in console logs) +echo "Running demo script..." +echo "Current workspace: $(pwd)" +echo "Timestamp: $(date)" + +# Write formatted output to CMUX_OUTPUT for toast display +cat >> "$CMUX_OUTPUT" << 'EOF' +## 🎉 Script Execution Demo + +✅ Script executed successfully! + +**Environment Variables Available:** +- `CMUX_OUTPUT`: Custom toast display +- `CMUX_PROMPT`: Send messages to agent +EOF + +# Write a prompt to CMUX_PROMPT to send a message to the agent +cat >> "$CMUX_PROMPT" << 'EOF' +The demo script has completed successfully. The script execution feature is working correctly with: +1. Custom toast output via CMUX_OUTPUT +2. Agent prompting via CMUX_PROMPT + +You can now create workspace-specific scripts to automate tasks and interact with the agent. +EOF diff --git a/.cmux/scripts/echo b/.cmux/scripts/echo new file mode 100755 index 000000000..6b52087c6 --- /dev/null +++ b/.cmux/scripts/echo @@ -0,0 +1,44 @@ +#!/usr/bin/env bash +# Description: Echo arguments demo - shows how to access script arguments +set -euo pipefail + +# Check if arguments were provided +if [ $# -eq 0 ]; then + cat >> "$CMUX_OUTPUT" << 'EOF' +## ⚠️ No Arguments Provided + +Usage: `/s echo ` + +Example: `/s echo hello world` +EOF + exit 0 +fi + +# Access arguments using standard bash positional parameters +# $1 = first arg, $2 = second arg, $@ = all args, $# = number of args + +cat >> "$CMUX_OUTPUT" << EOF +## 🔊 Echo Script + +**You said:** $@ + +**Arguments received:** +- Count: $# arguments +- First arg: ${1:-none} +- Second arg: ${2:-none} +- All args: $@ + +**Individual arguments:** +EOF + +# Loop through each argument +for i in $(seq 1 $#); do + echo "- Arg $i: ${!i}" >> "$CMUX_OUTPUT" +done + +# Optionally send a message to the agent +if [ $# -gt 3 ]; then + cat >> "$CMUX_PROMPT" << EOF +The user passed more than 3 arguments to the echo script. They seem to be testing the argument passing feature extensively! +EOF +fi diff --git a/docs/SUMMARY.md b/docs/SUMMARY.md index 3a6e273b6..b0f7f7fe9 100644 --- a/docs/SUMMARY.md +++ b/docs/SUMMARY.md @@ -13,6 +13,7 @@ - [SSH](./ssh.md) - [Forking](./fork.md) - [Init Hooks](./init-hooks.md) + - [Workspace Scripts](./scripts.md) - [Models](./models.md) - [Keyboard Shortcuts](./keybinds.md) - [Vim Mode](./vim-mode.md) diff --git a/docs/scripts.md b/docs/scripts.md new file mode 100644 index 000000000..f0eeeed10 --- /dev/null +++ b/docs/scripts.md @@ -0,0 +1,280 @@ +# Workspace Scripts + +Execute custom scripts from your workspace using slash commands with full auto-completion. + +## Overview + +Scripts are stored in `.cmux/scripts/` within each workspace and can be executed via `/script ` or the shorter `/s ` alias. Scripts run in the workspace directory with full access to project secrets and environment variables. + +**Key Point**: Scripts are workspace-specific, not project-global. Each workspace can have its own scripts in its `.cmux/scripts/` directory. + +## Creating Scripts + +1. **Create the scripts directory**: + + ```bash + mkdir -p .cmux/scripts + ``` + +2. **Add an executable script**: + + ```bash + #!/usr/bin/env bash + # Description: Deploy to staging environment + + echo "Deploying to staging..." + # Your deployment commands here + ``` + +3. **Make it executable**: + ```bash + chmod +x .cmux/scripts/deploy + ``` + +## Usage + +### Basic Execution + +Type `/s` or `/script` in chat to see available scripts with auto-completion: + +``` +/s deploy +``` + +### With Arguments + +Pass arguments to scripts: + +``` +/s deploy --dry-run +/script test --verbose --coverage +``` + +Arguments are passed directly to the script as `$1`, `$2`, etc. + +## Script Descriptions + +Add a description to make scripts easier to identify in auto-completion: + +```bash +#!/usr/bin/env bash +# Description: Run full test suite with coverage +``` + +or + +```bash +#!/usr/bin/env bash +# @description Run full test suite with coverage +``` + +The description appears in the command palette and slash command suggestions. + +## Execution Context + +Scripts run with: + +- **Working directory**: The workspace directory (same as bash_tool) +- **Environment**: Full workspace environment + project secrets + special cmux variables +- **Timeout**: 5 minutes by default +- **Streams**: stdout/stderr captured and logged + +### Environment Variables + +Scripts receive special environment variables for controlling cmux behavior: + +#### `CMUX_OUTPUT` + +Path to a temporary file for custom toast display content. Write markdown here for rich formatting in the UI toast: + +```bash +#!/usr/bin/env bash +# Description: Deploy with custom output + +echo "Deploying..." # Regular stdout for logs + +# Write formatted output for toast display +cat >> "$CMUX_OUTPUT" << 'EOF' +## 🚀 Deployment Complete + +✅ Successfully deployed to staging + +**Details:** +- Version: 2.1.3 +- Environment: staging +- Duration: 45s +EOF +``` + +The toast will display the markdown-formatted content instead of the default "Script completed successfully" message. + +#### `CMUX_PROMPT` + +Path to a temporary file for sending messages to the agent. Write prompts here to trigger agent actions: + +```bash +#!/usr/bin/env bash +# Description: Rebase with conflict handling + +if git pull --rebase origin main; then + echo "✅ Successfully rebased onto main" >> "$CMUX_OUTPUT" +else + echo "⚠️ Rebase conflicts detected" >> "$CMUX_OUTPUT" + + # Send conflict details to agent for analysis + cat >> "$CMUX_PROMPT" << 'EOF' +The rebase encountered conflicts. Please help resolve them: + +``` + +$(git status) + +``` + +Analyze the conflicts and propose resolutions. +EOF +fi +``` + +When the script completes, the prompt file content is automatically sent as a new user message, triggering the agent to respond. + +#### Combined Usage + +You can use both environment files together: + +```bash +#!/usr/bin/env bash +# Description: Run tests and report failures + +if npm test > test-output.txt 2>&1; then + echo "✅ All tests passed" >> "$CMUX_OUTPUT" +else + # Show summary in toast + echo "❌ Tests failed" >> "$CMUX_OUTPUT" + + # Ask agent to analyze and fix + cat >> "$CMUX_PROMPT" << EOF +The test suite failed. Please analyze and fix: + +\`\`\` +$(cat test-output.txt) +\`\`\` +EOF +fi +``` + +**Result:** + +1. Toast displays "❌ Tests failed" +2. Agent receives test output and starts analyzing +3. Agent proposes fixes + +### File Size Limits + +- **CMUX_OUTPUT**: Maximum 10KB (truncated if exceeded) +- **CMUX_PROMPT**: Maximum 100KB (truncated if exceeded) + +## Example Scripts + +### Deployment Script + +```bash +#!/usr/bin/env bash +# Description: Deploy application to specified environment +set -euo pipefail + +ENV=${1:-staging} +echo "Deploying to $ENV..." + +# Build +npm run build + +# Deploy +aws s3 sync dist/ s3://my-bucket-$ENV/ +echo "Deployment complete!" +``` + +### Test Runner + +```bash +#!/usr/bin/env bash +# Description: Run tests with optional flags +set -euo pipefail + +FLAGS="${@:---coverage}" +echo "Running tests with: $FLAGS" +npm test $FLAGS +``` + +### Database Migration + +```bash +#!/usr/bin/env bash +# Description: Run database migrations +set -euo pipefail + +echo "Running migrations..." +npm run migrate +echo "Migrations complete!" +``` + +## Tips + +**Idempotency**: Scripts can run multiple times. Make them idempotent when modifying shared state. + +**Error Handling**: Use `set -euo pipefail` to fail fast on errors. + +**Logging**: Echo progress messages - they appear in real-time during execution. + +**Arguments**: Always handle optional arguments with defaults: + +```bash +ENV=${1:-staging} # Default to 'staging' if no arg provided +``` + +**Exit Codes**: Non-zero exit codes are displayed as warnings in the UI. + +## Differences from Init Hooks + +| Feature | Init Hooks (`.cmux/init`) | Scripts (`.cmux/scripts/*`) | +| ------------- | -------------------------- | --------------------------- | +| **When Run** | Once on workspace creation | On-demand via slash command | +| **Execution** | Automatic | Manual user invocation | +| **Use Case** | Setup dependencies | Development tasks | +| **Arguments** | None | Supports arguments | +| **Frequency** | One-time per workspace | Any time, any number | + +## Script Discovery + +- Scripts are discovered automatically from `.cmux/scripts/` in the current workspace +- Only executable files appear in suggestions +- Non-executable files are ignored +- Cache refreshes when you switch workspaces + +## Keyboard Shortcuts + +Use existing chat and command palette shortcuts: + +- Type `/s` in chat for inline suggestions +- `Cmd+Shift+P` (or `Ctrl+Shift+P`) → `/s` for command palette +- Arrow keys to select, Enter to run + +## Troubleshooting + +**Script not appearing in suggestions?** + +- Ensure file is executable: `chmod +x .cmux/scripts/scriptname` +- Verify file is in `.cmux/scripts/` directory within your workspace +- Switch to another workspace and back to refresh the cache + +**Script fails with "not found"?** + +- Check shebang line is correct: `#!/usr/bin/env bash` +- Verify script has execute permissions +- Test script directly: `./.cmux/scripts/scriptname` + +**Script times out?** + +- Scripts have 5 minute timeout by default +- Split long-running operations into separate scripts +- Consider running processes in background if needed diff --git a/src/App.stories.tsx b/src/App.stories.tsx index baacef3a9..b7cdba0be 100644 --- a/src/App.stories.tsx +++ b/src/App.stories.tsx @@ -57,6 +57,19 @@ function setupMockAPI(options: { }), remove: () => Promise.resolve({ success: true }), fork: () => Promise.resolve({ success: false, error: "Not implemented in mock" }), + executeScript: () => + Promise.resolve({ + success: true, + data: { success: true, output: "Mock script output", exitCode: 0, wall_duration_ms: 100 }, + }), + listScripts: () => + Promise.resolve({ + success: true, + data: [ + { name: "deploy", description: "Deploy to staging", isExecutable: true }, + { name: "test", description: "Run tests", isExecutable: true }, + ], + }), openTerminal: () => Promise.resolve(undefined), onChat: () => () => undefined, onMetadata: () => () => undefined, @@ -398,6 +411,24 @@ export const ActiveWorkspaceWithChat: Story = { }), remove: () => Promise.resolve({ success: true }), fork: () => Promise.resolve({ success: false, error: "Not implemented in mock" }), + executeScript: () => + Promise.resolve({ + success: true, + data: { + success: true, + output: "Mock script output", + exitCode: 0, + wall_duration_ms: 100, + }, + }), + listScripts: () => + Promise.resolve({ + success: true, + data: [ + { name: "deploy", description: "Deploy to staging", isExecutable: true }, + { name: "test", description: "Run tests", isExecutable: true }, + ], + }), openTerminal: () => Promise.resolve(undefined), onChat: (workspaceId, callback) => { // Send chat history immediately when subscribed diff --git a/src/App.tsx b/src/App.tsx index d7b1ea208..0c12910bd 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -65,6 +65,37 @@ function AppInner() { setSidebarCollapsed((prev) => !prev); }, [setSidebarCollapsed]); + // Cache of scripts available in each workspace (lazy-loaded per workspace) + const [scriptCache, setScriptCache] = useState( + new Map>() + ); + + // Load scripts for current workspace when workspace is selected + // Reloads every time workspace changes to pick up new scripts + useEffect(() => { + if (!selectedWorkspace) return; + + const workspaceId = selectedWorkspace.workspaceId; + + const loadScriptsForWorkspace = async () => { + try { + const result = await window.api.workspace.listScripts(workspaceId); + if (result.success) { + // Filter to only executable scripts for suggestions + const executableScripts = result.data + .filter((s) => s.isExecutable) + .map((s) => ({ name: s.name, description: s.description })); + + setScriptCache((prev) => new Map(prev).set(workspaceId, executableScripts)); + } + } catch (error) { + console.error(`Failed to load scripts for ${workspaceId}:`, error); + } + }; + + void loadScriptsForWorkspace(); + }, [selectedWorkspace]); + // Telemetry tracking const telemetry = useTelemetry(); @@ -670,10 +701,17 @@ function AppInner() { ({ - providerNames: [], - workspaceId: selectedWorkspace?.workspaceId, - })} + getSlashContext={() => { + const availableScripts = selectedWorkspace + ? (scriptCache.get(selectedWorkspace.workspaceId) ?? []) + : []; + + return { + providerNames: [], + availableScripts, + workspaceId: selectedWorkspace?.workspaceId, + }; + }} /> {workspaceModalOpen && workspaceModalProject && ( invokeIPC(IPC_CHANNELS.WORKSPACE_GET_INFO, workspaceId), executeBash: (workspaceId, script, options) => invokeIPC(IPC_CHANNELS.WORKSPACE_EXECUTE_BASH, workspaceId, script, options), + executeScript: (workspaceId, scriptName, args) => + invokeIPC(IPC_CHANNELS.WORKSPACE_EXECUTE_SCRIPT, workspaceId, scriptName, args), + listScripts: (workspaceId) => invokeIPC(IPC_CHANNELS.WORKSPACE_LIST_SCRIPTS, workspaceId), openTerminal: (workspacePath) => invokeIPC(IPC_CHANNELS.WORKSPACE_OPEN_TERMINAL, workspacePath), onChat: (workspaceId, callback) => { diff --git a/src/components/ChatInput.tsx b/src/components/ChatInput.tsx index 26f71e2e7..00bcf873c 100644 --- a/src/components/ChatInput.tsx +++ b/src/components/ChatInput.tsx @@ -124,6 +124,9 @@ export const ChatInput: React.FC = ({ const [showCommandSuggestions, setShowCommandSuggestions] = useState(false); const [commandSuggestions, setCommandSuggestions] = useState([]); const [providerNames, setProviderNames] = useState([]); + const [availableScripts, setAvailableScripts] = useState< + Array<{ name: string; description?: string }> + >([]); const [toast, setToast] = useState(null); const [imageAttachments, setImageAttachments] = useState([]); const handleToastDismiss = useCallback(() => { @@ -257,10 +260,10 @@ export const ChatInput: React.FC = ({ // Watch input for slash commands useEffect(() => { - const suggestions = getSlashCommandSuggestions(input, { providerNames }); + const suggestions = getSlashCommandSuggestions(input, { providerNames, availableScripts }); setCommandSuggestions(suggestions); setShowCommandSuggestions(suggestions.length > 0); - }, [input, providerNames]); + }, [input, providerNames, availableScripts]); // Load provider names for suggestions useEffect(() => { @@ -284,6 +287,39 @@ export const ChatInput: React.FC = ({ }; }, []); + // Load available scripts for suggestions + useEffect(() => { + if (!workspaceId) { + setAvailableScripts([]); + return; + } + + let isMounted = true; + + const loadScripts = async () => { + try { + const result = await window.api.workspace.listScripts(workspaceId); + if (isMounted && result.success) { + const executableScripts = result.data + .filter((s) => s.isExecutable) + .map((s) => ({ name: s.name, description: s.description })); + setAvailableScripts(executableScripts); + } + } catch (error) { + console.error("Failed to load scripts:", error); + if (isMounted) { + setAvailableScripts([]); + } + } + }; + + void loadScripts(); + + return () => { + isMounted = false; + }; + }, [workspaceId]); + // Allow external components (e.g., CommandPalette) to insert text useEffect(() => { const handler = (e: Event) => { @@ -580,6 +616,85 @@ export const ChatInput: React.FC = ({ return; } + // Handle /script command + if (parsed.type === "script") { + setInput(""); // Clear input immediately + setIsSending(true); + + try { + const result = await window.api.workspace.executeScript( + workspaceId, + parsed.scriptName, + parsed.args + ); + + if (!result.success) { + setToast({ + id: Date.now().toString(), + type: "error", + title: "Script Execution Failed", + message: result.error, + }); + setInput(messageText); // Restore input on error + return; + } + + // Display script result + const toolResult = result.data; + const exitCode = toolResult.exitCode; + + // Use CMUX_OUTPUT content if present, otherwise fall back to default message + const toastMessage = + toolResult.outputFile ?? + (exitCode === 0 + ? `Script completed successfully` + : `Script exited with code ${exitCode}`); + + // If CMUX_PROMPT has content, send it as a new user message to the agent + if (toolResult.promptFile && toolResult.promptFile.trim().length > 0) { + const sendResult = await window.api.workspace.sendMessage( + workspaceId, + toolResult.promptFile, + sendMessageOptions + ); + + if (!sendResult.success) { + console.error("Failed to send prompt from script:", sendResult.error); + const errorToast = createErrorToast(sendResult.error); + errorToast.title = "Failed to Send Prompt"; + setToast(errorToast); + setIsSending(false); + return; // Exit early, don't show success toast + } + } + + // Only show success toast if prompt sent successfully (or no prompt to send) + setToast({ + id: Date.now().toString(), + type: exitCode === 0 ? "success" : "warning", + message: toastMessage, + }); + + // Log the full output to console for debugging + if (toolResult.output) { + console.log(`Script ${parsed.scriptName} output:`, toolResult.output); + } + } catch (error) { + const errorMsg = error instanceof Error ? error.message : "Failed to execute script"; + console.error("Script execution error:", error); + setToast({ + id: Date.now().toString(), + type: "error", + title: "Script Execution Failed", + message: errorMsg, + }); + setInput(messageText); // Restore input on error + } finally { + setIsSending(false); + } + return; + } + // Handle all other commands - show display toast const commandToast = createCommandToast(parsed); if (commandToast) { diff --git a/src/components/ChatInputToast.tsx b/src/components/ChatInputToast.tsx index b9993cd4a..4421f34ff 100644 --- a/src/components/ChatInputToast.tsx +++ b/src/components/ChatInputToast.tsx @@ -1,15 +1,18 @@ import type { ReactNode } from "react"; import React, { useEffect, useCallback } from "react"; import { cn } from "@/lib/utils"; +import ReactMarkdown from "react-markdown"; +import { markdownComponents } from "./Messages/MarkdownComponents"; -const toastTypeStyles: Record<"success" | "error", string> = { +const toastTypeStyles: Record<"success" | "error" | "warning", string> = { success: "bg-toast-success-bg border border-accent-dark text-toast-success-text", error: "bg-toast-error-bg border border-toast-error-border text-toast-error-text", + warning: "bg-amber-900 border border-yellow-600 text-yellow-100", }; export interface Toast { id: string; - type: "success" | "error"; + type: "success" | "error" | "warning"; title?: string; message: string; solution?: ReactNode; @@ -36,7 +39,7 @@ export const ChatInputToast: React.FC = ({ toast, onDismiss useEffect(() => { if (!toast) return; - // Only auto-dismiss success toasts + // Only auto-dismiss success toasts (warnings and errors stay until dismissed) if (toast.type === "success") { const duration = toast.duration ?? 3000; const timer = setTimeout(() => { @@ -48,10 +51,7 @@ export const ChatInputToast: React.FC = ({ toast, onDismiss }; } - // Error toasts stay until manually dismissed - return () => { - setIsLeaving(false); - }; + // Warning and error toasts stay until manually dismissed }, [toast, handleDismiss]); if (!toast) return null; @@ -108,9 +108,11 @@ export const ChatInputToast: React.FC = ({ toast, onDismiss {toast.type === "success" ? "✓" : "⚠"}
{toast.title &&
{toast.title}
} -
{toast.message}
+
+ {toast.message} +
- {toast.type === "error" && ( + {(toast.type === "error" || toast.type === "warning") && (