diff --git a/scripts/setup-git-hooks.mjs b/scripts/setup-git-hooks.mjs index ef8e86c93..54a073c50 100644 --- a/scripts/setup-git-hooks.mjs +++ b/scripts/setup-git-hooks.mjs @@ -22,6 +22,13 @@ if (!existsSync('.git')) { process.exit(0); } +// Check if git is available before attempting hook setup +const gitCheck = spawnSync('git', ['--version'], { stdio: 'pipe', encoding: 'utf8' }); +if (gitCheck.error || gitCheck.status !== 0) { + console.log('[setup-git-hooks] Skipping hook installation because git is not available.'); + process.exit(0); +} + const desiredHooksPath = '.husky'; const currentHooksPath = getGitConfig('core.hooksPath'); diff --git a/src/__tests__/main/web-server/WebServer.test.ts b/src/__tests__/main/web-server/WebServer.test.ts index daab2d2ec..c06ae17e8 100644 --- a/src/__tests__/main/web-server/WebServer.test.ts +++ b/src/__tests__/main/web-server/WebServer.test.ts @@ -25,7 +25,7 @@ describe('WebServer web asset resolution', () => { it('prefers built dist/web assets over the source web index', () => { const distWebDir = path.join(tempRoot, 'dist', 'web'); - mkdirSync(distWebDir, { recursive: true }); + mkdirSync(path.join(distWebDir, 'assets'), { recursive: true }); writeFileSync( path.join(distWebDir, 'index.html'), '' diff --git a/src/__tests__/main/web-server/web-server-factory.test.ts b/src/__tests__/main/web-server/web-server-factory.test.ts index b6b96adb7..cfcfb449a 100644 --- a/src/__tests__/main/web-server/web-server-factory.test.ts +++ b/src/__tests__/main/web-server/web-server-factory.test.ts @@ -68,6 +68,7 @@ vi.mock('../../../main/web-server/WebServer', () => { setGetCueSubscriptionsCallback = vi.fn(); setToggleCueSubscriptionCallback = vi.fn(); setGetCueActivityCallback = vi.fn(); + setTriggerCueSubscriptionCallback = vi.fn(); setGetUsageDashboardCallback = vi.fn(); setGetAchievementsCallback = vi.fn(); setWriteToTerminalCallback = vi.fn(); diff --git a/src/__tests__/renderer/components/CuePipelineEditor/drawers/TriggerDrawer.test.tsx b/src/__tests__/renderer/components/CuePipelineEditor/drawers/TriggerDrawer.test.tsx index a21a292ae..b0767c623 100644 --- a/src/__tests__/renderer/components/CuePipelineEditor/drawers/TriggerDrawer.test.tsx +++ b/src/__tests__/renderer/components/CuePipelineEditor/drawers/TriggerDrawer.test.tsx @@ -113,14 +113,14 @@ describe('TriggerDrawer', () => { expect(drawer.style.transform).toBe('translateX(0)'); }); - it('should render exactly 7 trigger types (no agent.completed)', () => { + it('should render exactly 8 trigger types (no agent.completed)', () => { const { container } = render( {}} theme={mockTheme} /> ); // Each trigger item is a draggable div; count them const draggableItems = container.querySelectorAll('[draggable="true"]'); - expect(draggableItems.length).toBe(7); + expect(draggableItems.length).toBe(8); }); it('should not show agent.completed when filtering by "agent"', () => { diff --git a/src/__tests__/renderer/hooks/cue/usePipelineState.test.ts b/src/__tests__/renderer/hooks/cue/usePipelineState.test.ts index 7f57b5da4..ac0ddc36f 100644 --- a/src/__tests__/renderer/hooks/cue/usePipelineState.test.ts +++ b/src/__tests__/renderer/hooks/cue/usePipelineState.test.ts @@ -144,9 +144,9 @@ function makePipeline(overrides?: Partial): CuePipeline { // ─── DEFAULT_TRIGGER_LABELS ────────────────────────────────────────────────── describe('DEFAULT_TRIGGER_LABELS', () => { - it('has entries for all eight event types', () => { + it('has entries for all nine event types', () => { const keys = Object.keys(DEFAULT_TRIGGER_LABELS); - expect(keys).toHaveLength(8); + expect(keys).toHaveLength(9); expect(keys).toContain('app.startup'); expect(keys).toContain('time.heartbeat'); expect(keys).toContain('time.scheduled'); @@ -155,6 +155,7 @@ describe('DEFAULT_TRIGGER_LABELS', () => { expect(keys).toContain('github.pull_request'); expect(keys).toContain('github.issue'); expect(keys).toContain('task.pending'); + expect(keys).toContain('cli.trigger'); }); }); diff --git a/src/__tests__/renderer/hooks/useRemoteIntegration.test.ts b/src/__tests__/renderer/hooks/useRemoteIntegration.test.ts index 69b5e4309..f241f4a4a 100644 --- a/src/__tests__/renderer/hooks/useRemoteIntegration.test.ts +++ b/src/__tests__/renderer/hooks/useRemoteIntegration.test.ts @@ -188,6 +188,10 @@ describe('useRemoteIntegration', () => { return () => {}; }), sendRemoteGetGitDiffResponse: vi.fn(), + onRemoteTriggerCueSubscription: vi.fn().mockImplementation(() => { + return () => {}; + }), + sendRemoteTriggerCueSubscriptionResponse: vi.fn(), }; const mockLive = { @@ -217,6 +221,11 @@ describe('useRemoteIntegration', () => { updateSessionName: vi.fn().mockResolvedValue(true), }; + const mockCue = { + ...window.maestro.cue, + triggerSubscription: vi.fn().mockResolvedValue(true), + }; + beforeEach(() => { vi.clearAllMocks(); onRemoteCommandHandler = undefined; @@ -239,6 +248,7 @@ describe('useRemoteIntegration', () => { claude: mockClaude as typeof window.maestro.claude, agentSessions: mockAgentSessions as typeof window.maestro.agentSessions, history: mockHistory as typeof window.maestro.history, + cue: mockCue as typeof window.maestro.cue, }; }); diff --git a/src/cli/commands/cue-trigger.ts b/src/cli/commands/cue-trigger.ts new file mode 100644 index 000000000..4b0d77a05 --- /dev/null +++ b/src/cli/commands/cue-trigger.ts @@ -0,0 +1,69 @@ +// Cue trigger command - manually trigger a Cue subscription by name + +import { withMaestroClient } from '../services/maestro-client'; + +interface CueTriggerOptions { + prompt?: string; + json?: boolean; +} + +export async function cueTrigger( + subscriptionName: string, + options: CueTriggerOptions +): Promise { + try { + const result = await withMaestroClient(async (client) => { + return client.sendCommand<{ + type: string; + success: boolean; + subscriptionName: string; + error?: string; + }>( + { + type: 'trigger_cue_subscription', + subscriptionName, + prompt: options.prompt, + }, + 'trigger_cue_subscription_result' + ); + }); + + if (options.json) { + console.log( + JSON.stringify({ + type: 'trigger_result', + success: result.success, + subscriptionName, + ...(options.prompt !== undefined ? { prompt: options.prompt } : {}), + ...(result.error ? { error: result.error } : {}), + }) + ); + if (!result.success) { + process.exitCode = 1; + } + } else if (result.success) { + console.log( + `Triggered Cue subscription "${subscriptionName}"${options.prompt ? ' with custom prompt' : ''}` + ); + } else { + console.error( + `Subscription "${subscriptionName}" not found or could not be triggered${ + result.error ? `: ${result.error}` : '' + }` + ); + process.exit(1); + } + } catch (error) { + if (options.json) { + console.log( + JSON.stringify({ + type: 'error', + error: error instanceof Error ? error.message : String(error), + }) + ); + } else { + console.error(`Error: ${error instanceof Error ? error.message : String(error)}`); + } + process.exit(1); + } +} diff --git a/src/cli/index.ts b/src/cli/index.ts index da7fb661c..2d68a70b2 100644 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -18,6 +18,7 @@ import { refreshFiles } from './commands/refresh-files'; import { refreshAutoRun } from './commands/refresh-auto-run'; import { status } from './commands/status'; import { autoRun } from './commands/auto-run'; +import { cueTrigger } from './commands/cue-trigger'; import { settingsList } from './commands/settings-list'; import { settingsGet } from './commands/settings-get'; import { settingsSet } from './commands/settings-set'; @@ -161,6 +162,16 @@ program .option('--reset-on-completion', 'Enable reset-on-completion for all documents') .action(autoRun); +// Cue commands - interact with Maestro Cue automation +const cue = program.command('cue').description('Interact with Maestro Cue automation'); + +cue + .command('trigger ') + .description('Manually trigger a Cue subscription by name') + .option('-p, --prompt ', 'Override the subscription prompt with custom text') + .option('--json', 'Output as JSON (for scripting)') + .action(cueTrigger); + // Status command - check if Maestro desktop app is running and reachable program .command('status') diff --git a/src/main/cue/config/cue-config-validator.ts b/src/main/cue/config/cue-config-validator.ts index 015a4a8ab..ed6571ddb 100644 --- a/src/main/cue/config/cue-config-validator.ts +++ b/src/main/cue/config/cue-config-validator.ts @@ -196,6 +196,8 @@ export function validateCueConfigDocument(config: unknown): { valid: boolean; er } } else if (event === 'app.startup') { // No additional required fields for the startup trigger. + } else if (event === 'cli.trigger') { + // No additional required fields — triggered manually via maestro-cli. } else if ( sub.event && typeof sub.event === 'string' && diff --git a/src/main/cue/cue-dispatch-service.ts b/src/main/cue/cue-dispatch-service.ts index df5a5f87d..04f5f5ec6 100644 --- a/src/main/cue/cue-dispatch-service.ts +++ b/src/main/cue/cue-dispatch-service.ts @@ -21,7 +21,8 @@ export interface CueDispatchService { sub: CueSubscription, event: CueEvent, sourceSessionName: string, - chainDepth?: number + chainDepth?: number, + promptOverride?: string ): void; } @@ -32,7 +33,8 @@ export function createCueDispatchService(deps: CueDispatchServiceDeps): CueDispa sub: CueSubscription, event: CueEvent, sourceSessionName: string, - chainDepth?: number + chainDepth?: number, + promptOverride?: string ): void { if (sub.fan_out && sub.fan_out.length > 0) { const targetNames = sub.fan_out.join(', '); @@ -62,7 +64,7 @@ export function createCueDispatchService(deps: CueDispatchServiceDeps): CueDispa // The normalizer (cue-config-normalizer.ts) resolves prompt_file → prompt // content at config load time. sub.prompt is always a string post-normalization. const perTargetPrompt = sub.fan_out_prompts?.[i]; - const prompt = perTargetPrompt ?? sub.prompt; + const prompt = promptOverride ?? perTargetPrompt ?? sub.prompt; if (!prompt) { deps.onLog( 'warn', @@ -82,7 +84,7 @@ export function createCueDispatchService(deps: CueDispatchServiceDeps): CueDispa return; } - const prompt = sub.prompt; + const prompt = promptOverride ?? sub.prompt; if (!prompt) { deps.onLog('warn', `[CUE] "${sub.name}" has no prompt — skipping dispatch`); return; diff --git a/src/main/cue/cue-engine.ts b/src/main/cue/cue-engine.ts index 7bf41d450..f7ad7f87f 100644 --- a/src/main/cue/cue-engine.ts +++ b/src/main/cue/cue-engine.ts @@ -352,17 +352,30 @@ export class CueEngine { * Creates a synthetic event and dispatches through the normal execution path. * Returns true if the subscription was found and triggered. */ - triggerSubscription(subscriptionName: string): boolean { + triggerSubscription(subscriptionName: string, promptOverride?: string): boolean { for (const [sessionId, state] of this.registry.snapshot()) { for (const sub of state.config.subscriptions) { if (sub.name !== subscriptionName) continue; if (sub.agent_id && sub.agent_id !== sessionId) continue; - const event = createCueEvent(sub.event, sub.name, { manual: true }); + const event = createCueEvent(sub.event, sub.name, { + manual: true, + ...(promptOverride ? { cliPrompt: promptOverride } : {}), + }); - this.deps.onLog('cue', `[CUE] "${sub.name}" manually triggered`); + this.deps.onLog( + 'cue', + `[CUE] "${sub.name}" manually triggered${promptOverride ? ' (with prompt override)' : ''}` + ); state.lastTriggered = event.timestamp; - this.dispatchService.dispatchSubscription(sessionId, sub, event, 'manual'); + this.dispatchService.dispatchSubscription( + sessionId, + sub, + event, + 'manual', + undefined, + promptOverride + ); return true; } } diff --git a/src/main/cue/cue-template-context-builder.ts b/src/main/cue/cue-template-context-builder.ts index 105ccfb54..e685416b1 100644 --- a/src/main/cue/cue-template-context-builder.ts +++ b/src/main/cue/cue-template-context-builder.ts @@ -82,6 +82,11 @@ function buildGitHubContext(event: CueEvent): Record { enricherRegistry.set('github.pull_request', (event) => buildGitHubContext(event)); enricherRegistry.set('github.issue', (event) => buildGitHubContext(event)); +/** cli.trigger enricher — adds CLI prompt override field. */ +enricherRegistry.set('cli.trigger', (event) => ({ + cliPrompt: String(event.payload.cliPrompt ?? ''), +})); + // ─── Public API ────────────────────────────────────────────────────────────── /** diff --git a/src/main/cue/triggers/cue-trigger-source-registry.ts b/src/main/cue/triggers/cue-trigger-source-registry.ts index e8db4df66..5ca6d5285 100644 --- a/src/main/cue/triggers/cue-trigger-source-registry.ts +++ b/src/main/cue/triggers/cue-trigger-source-registry.ts @@ -43,8 +43,9 @@ export function createTriggerSource( return createCueGitHubPollerTriggerSource(ctx); case 'agent.completed': case 'app.startup': - // These are not timer/watcher-driven — the runtime handles them - // directly via the completion service / startup loop. + case 'cli.trigger': + // These are not timer/watcher-driven ��� the runtime handles them + // directly via the completion service / startup loop / CLI command. return null; default: { const unsupported: never = eventType; diff --git a/src/main/ipc/handlers/cue.ts b/src/main/ipc/handlers/cue.ts index 35e8be3ee..83f5c2f23 100644 --- a/src/main/ipc/handlers/cue.ts +++ b/src/main/ipc/handlers/cue.ts @@ -142,8 +142,8 @@ export function registerCueHandlers(deps: CueHandlerDependencies): void { 'cue:triggerSubscription', withIpcErrorLogging( handlerOpts('triggerSubscription'), - async (options: { subscriptionName: string }): Promise => { - return requireEngine().triggerSubscription(options.subscriptionName); + async (options: { subscriptionName: string; prompt?: string }): Promise => { + return requireEngine().triggerSubscription(options.subscriptionName, options.prompt); } ) ); diff --git a/src/main/preload/cue.ts b/src/main/preload/cue.ts index 1f58235c6..48ca9ed63 100644 --- a/src/main/preload/cue.ts +++ b/src/main/preload/cue.ts @@ -59,9 +59,9 @@ export function createCueApi() { // Stop all running Cue executions stopAll: (): Promise => ipcRenderer.invoke('cue:stopAll'), - // Manually trigger a subscription by name (Run Now) - triggerSubscription: (subscriptionName: string): Promise => - ipcRenderer.invoke('cue:triggerSubscription', { subscriptionName }), + // Manually trigger a subscription by name (Run Now), with optional prompt override + triggerSubscription: (subscriptionName: string, prompt?: string): Promise => + ipcRenderer.invoke('cue:triggerSubscription', { subscriptionName, prompt }), // Get queue status per session getQueueStatus: (): Promise> => ipcRenderer.invoke('cue:getQueueStatus'), diff --git a/src/main/preload/process.ts b/src/main/preload/process.ts index 5ba1e17a5..3a0526996 100644 --- a/src/main/preload/process.ts +++ b/src/main/preload/process.ts @@ -1083,6 +1083,41 @@ export function createProcessApi() { ipcRenderer.send(responseChannel, result); }, + /** + * Listen for remote trigger Cue subscription requests (from web/CLI clients) + */ + onRemoteTriggerCueSubscription: ( + callback: ( + subscriptionName: string, + prompt: string | undefined, + responseChannel: string + ) => void + ): (() => void) => { + const handler = ( + _: unknown, + subscriptionName: string, + prompt: string | undefined, + responseChannel: string + ) => { + try { + Promise.resolve(callback(subscriptionName, prompt, responseChannel)).catch(() => { + ipcRenderer.send(responseChannel, false); + }); + } catch { + ipcRenderer.send(responseChannel, false); + } + }; + ipcRenderer.on('remote:triggerCueSubscription', handler); + return () => ipcRenderer.removeListener('remote:triggerCueSubscription', handler); + }, + + /** + * Send response for remote trigger Cue subscription + */ + sendRemoteTriggerCueSubscriptionResponse: (responseChannel: string, result: unknown): void => { + ipcRenderer.send(responseChannel, result); + }, + /** * Subscribe to stderr from runCommand (separate stream) */ diff --git a/src/main/web-server/WebServer.ts b/src/main/web-server/WebServer.ts index 05eed3493..b2fb080d5 100644 --- a/src/main/web-server/WebServer.ts +++ b/src/main/web-server/WebServer.ts @@ -102,6 +102,7 @@ import type { SummarizeContextCallback, GetCueSubscriptionsCallback, ToggleCueSubscriptionCallback, + TriggerCueSubscriptionCallback, GetCueActivityCallback, CueActivityEntry, CueSubscriptionInfo, @@ -239,11 +240,13 @@ export class WebServer { return false; } + const assetsPath = path.join(candidatePath, 'assets'); + try { const html = readFileSync(indexPath, 'utf-8'); const referencesDevEntrypoint = html.includes('src="/main.tsx"') || html.includes("src='/main.tsx'"); - return !referencesDevEntrypoint; + return !referencesDevEntrypoint && existsSync(assetsPath); } catch (error) { const err = error as NodeJS.ErrnoException; if (err.code === 'ENOENT') { @@ -537,6 +540,10 @@ export class WebServer { this.callbackRegistry.setToggleCueSubscriptionCallback(callback); } + setTriggerCueSubscriptionCallback(callback: TriggerCueSubscriptionCallback): void { + this.callbackRegistry.setTriggerCueSubscriptionCallback(callback); + } + setGetCueActivityCallback(callback: GetCueActivityCallback): void { this.callbackRegistry.setGetCueActivityCallback(callback); } diff --git a/src/main/web-server/handlers/messageHandlers.ts b/src/main/web-server/handlers/messageHandlers.ts index 12d4442cd..51ff41085 100644 --- a/src/main/web-server/handlers/messageHandlers.ts +++ b/src/main/web-server/handlers/messageHandlers.ts @@ -174,6 +174,7 @@ export interface MessageHandlerCallbacks { getCueSubscriptions: (sessionId?: string) => Promise; toggleCueSubscription: (subscriptionId: string, enabled: boolean) => Promise; getCueActivity: (sessionId?: string, limit?: number) => Promise; + triggerCueSubscription: (subscriptionName: string, prompt?: string) => Promise; getUsageDashboard: (timeRange: 'day' | 'week' | 'month' | 'all') => Promise; getAchievements: () => Promise; writeToTerminal: (sessionId: string, data: string) => boolean; @@ -413,6 +414,10 @@ export class WebSocketMessageHandler { this.handleGetCueActivity(client, message); break; + case 'trigger_cue_subscription': + this.handleTriggerCueSubscription(client, message); + break; + case 'get_usage_dashboard': this.handleGetUsageDashboard(client, message); break; @@ -2313,6 +2318,45 @@ export class WebSocketMessageHandler { }); } + /** + * Handle trigger_cue_subscription message - manually trigger a Cue subscription + */ + private handleTriggerCueSubscription(client: WebClient, message: WebClientMessage): void { + const subscriptionName = message.subscriptionName; + const prompt = message.prompt; + + if (typeof subscriptionName !== 'string' || subscriptionName.trim() === '') { + this.sendError(client, 'Missing subscriptionName'); + return; + } + if (prompt !== undefined && typeof prompt !== 'string') { + this.sendError(client, 'Invalid prompt: must be a string when provided'); + return; + } + + if (!this.callbacks.triggerCueSubscription) { + this.sendError(client, 'Cue trigger not available'); + return; + } + + this.callbacks + .triggerCueSubscription(subscriptionName, prompt as string | undefined) + .then((success) => { + this.send(client, { + type: 'trigger_cue_subscription_result', + success, + subscriptionName, + requestId: message.requestId, + timestamp: Date.now(), + }); + }) + .catch((error) => { + const err = error instanceof Error ? error : new Error(String(error)); + logger.error(`Failed to trigger Cue subscription: ${err.message}`, 'WebSocket'); + this.sendError(client, `Failed to trigger Cue subscription: ${err.message}`); + }); + } + /** * Handle get_usage_dashboard message - fetch usage analytics data */ diff --git a/src/main/web-server/managers/CallbackRegistry.ts b/src/main/web-server/managers/CallbackRegistry.ts index 004d5883f..3717dd131 100644 --- a/src/main/web-server/managers/CallbackRegistry.ts +++ b/src/main/web-server/managers/CallbackRegistry.ts @@ -61,6 +61,7 @@ import type { GetCueSubscriptionsCallback, ToggleCueSubscriptionCallback, GetCueActivityCallback, + TriggerCueSubscriptionCallback, CueSubscriptionInfo, CueActivityEntry, GetUsageDashboardCallback, @@ -123,6 +124,7 @@ export interface WebServerCallbacks { getCueSubscriptions: GetCueSubscriptionsCallback | null; toggleCueSubscription: ToggleCueSubscriptionCallback | null; getCueActivity: GetCueActivityCallback | null; + triggerCueSubscription: TriggerCueSubscriptionCallback | null; getUsageDashboard: GetUsageDashboardCallback | null; getAchievements: GetAchievementsCallback | null; } @@ -177,6 +179,7 @@ export class CallbackRegistry { getCueSubscriptions: null, toggleCueSubscription: null, getCueActivity: null, + triggerCueSubscription: null, getUsageDashboard: null, getAchievements: null, }; @@ -450,6 +453,11 @@ export class CallbackRegistry { return this.callbacks.getCueActivity(sessionId, limit); } + async triggerCueSubscription(subscriptionName: string, prompt?: string): Promise { + if (!this.callbacks.triggerCueSubscription) return false; + return this.callbacks.triggerCueSubscription(subscriptionName, prompt); + } + async getUsageDashboard( timeRange: 'day' | 'week' | 'month' | 'all' ): Promise { @@ -670,6 +678,10 @@ export class CallbackRegistry { this.callbacks.getCueActivity = callback; } + setTriggerCueSubscriptionCallback(callback: TriggerCueSubscriptionCallback): void { + this.callbacks.triggerCueSubscription = callback; + } + setGetUsageDashboardCallback(callback: GetUsageDashboardCallback): void { this.callbacks.getUsageDashboard = callback; } diff --git a/src/main/web-server/types.ts b/src/main/web-server/types.ts index 7da71f69c..4c2087f02 100644 --- a/src/main/web-server/types.ts +++ b/src/main/web-server/types.ts @@ -589,6 +589,10 @@ export type GetCueActivityCallback = ( sessionId?: string, limit?: number ) => Promise; +export type TriggerCueSubscriptionCallback = ( + subscriptionName: string, + prompt?: string +) => Promise; // ============================================================================= // Usage Dashboard Types diff --git a/src/main/web-server/web-server-factory.ts b/src/main/web-server/web-server-factory.ts index 71df985f8..10761c42b 100644 --- a/src/main/web-server/web-server-factory.ts +++ b/src/main/web-server/web-server-factory.ts @@ -1706,6 +1706,53 @@ export function createWebServerFactory(deps: WebServerFactoryDependencies) { }); }); + // Trigger a Cue subscription by name — uses IPC request-response pattern + server.setTriggerCueSubscriptionCallback(async (subscriptionName: string, prompt?: string) => { + const mainWindow = getMainWindow(); + if (!mainWindow) { + logger.warn('mainWindow is null for triggerCueSubscription', 'WebServer'); + return false; + } + + return new Promise((resolve) => { + const responseChannel = `remote:triggerCueSubscription:response:${randomUUID()}`; + let resolved = false; + let timeoutId: ReturnType | undefined; + + const handleResponse = (_event: Electron.IpcMainEvent, result: any) => { + if (resolved) return; + resolved = true; + if (timeoutId) clearTimeout(timeoutId); + resolve(result ?? false); + }; + + ipcMain.once(responseChannel, handleResponse); + if (!isWebContentsAvailable(mainWindow)) { + logger.warn('webContents is not available for triggerCueSubscription', 'WebServer'); + ipcMain.removeListener(responseChannel, handleResponse); + resolve(false); + return; + } + mainWindow.webContents.send( + 'remote:triggerCueSubscription', + subscriptionName, + prompt, + responseChannel + ); + + timeoutId = setTimeout(() => { + if (resolved) return; + resolved = true; + ipcMain.removeListener(responseChannel, handleResponse); + logger.warn( + `triggerCueSubscription callback timed out for ${subscriptionName}`, + 'WebServer' + ); + resolve(false); + }, 10000); + }); + }); + // ============ Usage Dashboard & Achievements Callbacks ============ // Get usage dashboard data — aggregates from session usage stats via IPC diff --git a/src/renderer/components/CuePipelineEditor/cueEventConstants.ts b/src/renderer/components/CuePipelineEditor/cueEventConstants.ts index 656458b5c..0776040f7 100644 --- a/src/renderer/components/CuePipelineEditor/cueEventConstants.ts +++ b/src/renderer/components/CuePipelineEditor/cueEventConstants.ts @@ -5,7 +5,16 @@ * TriggerNode, TriggerDrawer, NodeConfigPanel, and PipelineCanvas. */ -import { Clock, FileText, Zap, GitPullRequest, CircleDot, CheckSquare, Power } from 'lucide-react'; +import { + Clock, + FileText, + Zap, + GitPullRequest, + CircleDot, + CheckSquare, + Power, + Terminal, +} from 'lucide-react'; import type { CueEventType } from '../../../shared/cue-pipeline-types'; /** Icon component for each event type */ @@ -18,6 +27,7 @@ export const EVENT_ICONS: Record = { 'github.pull_request': GitPullRequest, 'github.issue': CircleDot, 'task.pending': CheckSquare, + 'cli.trigger': Terminal, }; /** Display label for each event type */ @@ -30,6 +40,7 @@ export const EVENT_LABELS: Record = { 'github.pull_request': 'Pull Request', 'github.issue': 'GitHub Issue', 'task.pending': 'Pending Task', + 'cli.trigger': 'CLI Trigger', }; /** Default prompt templates for event types that benefit from pre-populated context */ @@ -61,4 +72,5 @@ export const EVENT_COLORS: Record = { 'github.pull_request': '#a855f7', 'github.issue': '#f97316', 'task.pending': '#06b6d4', + 'cli.trigger': '#64748b', }; diff --git a/src/renderer/components/CuePipelineEditor/drawers/TriggerDrawer.tsx b/src/renderer/components/CuePipelineEditor/drawers/TriggerDrawer.tsx index 6b9813d10..b58c4336d 100644 --- a/src/renderer/components/CuePipelineEditor/drawers/TriggerDrawer.tsx +++ b/src/renderer/components/CuePipelineEditor/drawers/TriggerDrawer.tsx @@ -68,6 +68,13 @@ const TRIGGER_ITEMS: TriggerItem[] = [ icon: EVENT_ICONS['task.pending'], color: EVENT_COLORS['task.pending'], }, + { + eventType: 'cli.trigger', + label: 'CLI Trigger', + description: 'Triggered via maestro-cli', + icon: EVENT_ICONS['cli.trigger'], + color: EVENT_COLORS['cli.trigger'], + }, ]; function handleDragStart(e: React.DragEvent, item: TriggerItem) { diff --git a/src/renderer/components/CuePipelineEditor/panels/triggers/TriggerConfig.tsx b/src/renderer/components/CuePipelineEditor/panels/triggers/TriggerConfig.tsx index 14529f440..0cfde149f 100644 --- a/src/renderer/components/CuePipelineEditor/panels/triggers/TriggerConfig.tsx +++ b/src/renderer/components/CuePipelineEditor/panels/triggers/TriggerConfig.tsx @@ -258,6 +258,17 @@ export function TriggerConfig({ node, theme, onUpdateNode }: TriggerConfigProps) ); + case 'cli.trigger': + return ( +
+ {nameField} +
+ Triggered manually via{' '} + maestro-cli cue trigger "{data.customLabel || data.label || 'name'}". + Supports an optional --prompt override. +
+
+ ); default: return null; } diff --git a/src/renderer/components/CuePipelineEditor/utils/pipelineGraph.ts b/src/renderer/components/CuePipelineEditor/utils/pipelineGraph.ts index 82551024f..9c96f09fb 100644 --- a/src/renderer/components/CuePipelineEditor/utils/pipelineGraph.ts +++ b/src/renderer/components/CuePipelineEditor/utils/pipelineGraph.ts @@ -41,6 +41,8 @@ export function getTriggerConfigSummary(data: TriggerNodeData): string { return config.watch ?? 'tasks'; case 'agent.completed': return 'agent done'; + case 'cli.trigger': + return 'cli'; default: return ''; } diff --git a/src/renderer/components/CuePipelineEditor/utils/yamlToPipeline.ts b/src/renderer/components/CuePipelineEditor/utils/yamlToPipeline.ts index 6a9a3d936..e2c1b3d56 100644 --- a/src/renderer/components/CuePipelineEditor/utils/yamlToPipeline.ts +++ b/src/renderer/components/CuePipelineEditor/utils/yamlToPipeline.ts @@ -148,6 +148,8 @@ function triggerLabel(eventType: CueEventType): string { return 'Task Pending'; case 'agent.completed': return 'Agent Done'; + case 'cli.trigger': + return 'CLI Trigger'; default: return 'Trigger'; } diff --git a/src/renderer/constants/cuePatterns.ts b/src/renderer/constants/cuePatterns.ts index ee65a9392..c1e792ff1 100644 --- a/src/renderer/constants/cuePatterns.ts +++ b/src/renderer/constants/cuePatterns.ts @@ -219,6 +219,26 @@ subscriptions: # {{CUE_TASK_COUNT}} — Number of unchecked tasks found # {{CUE_TASK_LIST}} — Formatted list of pending tasks with line numbers # {{CUE_TASK_CONTENT}} — Full file content (truncated to 10K chars) +`, + }, + { + id: 'cli-trigger', + name: 'CLI Trigger', + description: 'On-demand trigger via maestro-cli', + explanation: + 'Fires only when explicitly triggered from the command line with `maestro-cli cue trigger `. Supports an optional `--prompt` flag to override or supply the prompt at invocation time. Ideal for deployment scripts, CI/CD integration, or ad-hoc automation.', + yaml: `subscriptions: + - name: "deploy" + event: cli.trigger + prompt: "Run the deployment pipeline for the current branch" + enabled: true + +# Usage: +# maestro-cli cue trigger deploy +# maestro-cli cue trigger deploy --prompt "Deploy to staging only" +# +# Template variables available in your prompt: +# {{CUE_CLI_PROMPT}} — The prompt text passed via --prompt flag (empty if not provided) `, }, ]; diff --git a/src/renderer/constants/cueYamlDefaults.ts b/src/renderer/constants/cueYamlDefaults.ts index 509759524..e66458c68 100644 --- a/src/renderer/constants/cueYamlDefaults.ts +++ b/src/renderer/constants/cueYamlDefaults.ts @@ -45,6 +45,11 @@ export const CUE_YAML_TEMPLATE = `# .maestro/cue.yaml # prompt: prompts/process-task.md # enabled: true # +# - name: "deploy" +# event: cli.trigger +# prompt: "Run the deployment pipeline for the current branch" +# enabled: true +# # settings: # timeout_minutes: 30 # timeout_on_fail: break diff --git a/src/renderer/global.d.ts b/src/renderer/global.d.ts index 5ee09b3ff..e5249f84c 100644 --- a/src/renderer/global.d.ts +++ b/src/renderer/global.d.ts @@ -468,6 +468,14 @@ interface MaestroAPI { callback: (sessionId: string, filePath: string | undefined, responseChannel: string) => void ) => () => void; sendRemoteGetGitDiffResponse: (responseChannel: string, result: any) => void; + onRemoteTriggerCueSubscription: ( + callback: ( + subscriptionName: string, + prompt: string | undefined, + responseChannel: string + ) => void + ) => () => void; + sendRemoteTriggerCueSubscriptionResponse: (responseChannel: string, result: unknown) => void; onStderr: (callback: (sessionId: string, data: string) => void) => () => void; onCommandExit: (callback: (sessionId: string, code: number) => void) => () => void; onUsage: (callback: (sessionId: string, usageStats: UsageStats) => void) => () => void; @@ -3044,7 +3052,7 @@ interface MaestroAPI { disable: () => Promise; stopRun: (runId: string) => Promise; stopAll: () => Promise; - triggerSubscription: (subscriptionName: string) => Promise; + triggerSubscription: (subscriptionName: string, prompt?: string) => Promise; getQueueStatus: () => Promise>; refreshSession: (sessionId: string, projectRoot: string) => Promise; removeSession: (sessionId: string) => Promise; diff --git a/src/renderer/hooks/cue/usePipelineState.ts b/src/renderer/hooks/cue/usePipelineState.ts index 2132f9e7b..29d9ca9d8 100644 --- a/src/renderer/hooks/cue/usePipelineState.ts +++ b/src/renderer/hooks/cue/usePipelineState.ts @@ -47,6 +47,7 @@ export const DEFAULT_TRIGGER_LABELS: Record = { 'github.pull_request': 'Pull Request', 'github.issue': 'Issue', 'task.pending': 'Pending Task', + 'cli.trigger': 'CLI Trigger', }; /** Validates pipeline graph before save. Returns array of error messages. */ diff --git a/src/renderer/hooks/remote/useRemoteIntegration.ts b/src/renderer/hooks/remote/useRemoteIntegration.ts index ce26320d0..dd3120415 100644 --- a/src/renderer/hooks/remote/useRemoteIntegration.ts +++ b/src/renderer/hooks/remote/useRemoteIntegration.ts @@ -880,5 +880,21 @@ export function useRemoteIntegration(deps: UseRemoteIntegrationDeps): UseRemoteI return () => clearInterval(intervalId); }, [isLiveMode, sessionsRef]); + // Handle remote trigger Cue subscription requests (from web/CLI clients) + useEffect(() => { + const unsubscribe = window.maestro.process.onRemoteTriggerCueSubscription( + async (subscriptionName: string, prompt: string | undefined, responseChannel: string) => { + try { + const result = await window.maestro.cue.triggerSubscription(subscriptionName, prompt); + window.maestro.process.sendRemoteTriggerCueSubscriptionResponse(responseChannel, result); + } catch (error) { + console.error('[Remote Cue Trigger] Failed:', subscriptionName, error); + window.maestro.process.sendRemoteTriggerCueSubscriptionResponse(responseChannel, false); + } + } + ); + return unsubscribe; + }, []); + return {}; } diff --git a/src/shared/cue/contracts.ts b/src/shared/cue/contracts.ts index c8d110dc1..514e611c5 100644 --- a/src/shared/cue/contracts.ts +++ b/src/shared/cue/contracts.ts @@ -27,7 +27,8 @@ export type CueEventType = | 'agent.completed' | 'github.pull_request' | 'github.issue' - | 'task.pending'; + | 'task.pending' + | 'cli.trigger'; /** All valid event type values */ export const CUE_EVENT_TYPES: CueEventType[] = [ @@ -39,6 +40,7 @@ export const CUE_EVENT_TYPES: CueEventType[] = [ 'github.pull_request', 'github.issue', 'task.pending', + 'cli.trigger', ]; /** Valid GitHub state filters for polling triggers */ diff --git a/src/shared/templateVariables.ts b/src/shared/templateVariables.ts index 31dc1dd88..401e5605a 100644 --- a/src/shared/templateVariables.ts +++ b/src/shared/templateVariables.ts @@ -55,7 +55,7 @@ import { buildSessionDeepLink, buildGroupDeepLink } from './deep-link-urls'; * {{MAESTRO_CLI_PATH}} - Platform-appropriate path to maestro-cli * * Cue Variables (Cue automation only): - * {{CUE_EVENT_TYPE}} - Cue event type (app.startup, time.heartbeat, time.scheduled, file.changed, agent.completed, github.*, task.pending) + * {{CUE_EVENT_TYPE}} - Cue event type (app.startup, time.heartbeat, time.scheduled, file.changed, agent.completed, github.*, task.pending, cli.trigger) * {{CUE_EVENT_TIMESTAMP}} - Cue event timestamp * {{CUE_TRIGGER_NAME}} - Cue trigger/subscription name * {{CUE_RUN_ID}} - Cue run UUID @@ -90,6 +90,8 @@ import { buildSessionDeepLink, buildGroupDeepLink } from './deep-link-urls'; * {{CUE_GH_BRANCH}} - Head branch (github.pull_request events) * {{CUE_GH_BASE_BRANCH}} - Base branch (github.pull_request events) * {{CUE_GH_ASSIGNEES}} - Comma-separated assignees (github.issue events) + * + * {{CUE_CLI_PROMPT}} - Prompt text passed via --prompt flag (cli.trigger events) */ /** @@ -198,6 +200,8 @@ export interface TemplateContext { ghBaseBranch?: string; ghAssignees?: string; ghMergedAt?: string; + // CLI trigger fields (cli.trigger) + cliPrompt?: string; }; } @@ -216,6 +220,11 @@ export const TEMPLATE_VARIABLES = [ { variable: '{{AUTORUN_FOLDER}}', description: 'Auto Run folder path', autoRunOnly: true }, { variable: '{{TAB_NAME}}', description: 'Custom tab name' }, { variable: '{{CONTEXT_USAGE}}', description: 'Context usage %' }, + { + variable: '{{CUE_CLI_PROMPT}}', + description: 'CLI prompt override (cli.trigger events)', + cueOnly: true, + }, { variable: '{{CUE_EVENT_TIMESTAMP}}', description: 'Cue event timestamp', cueOnly: true }, { variable: '{{CUE_EVENT_TYPE}}', description: 'Cue event type', cueOnly: true }, { @@ -438,6 +447,7 @@ export function substituteTemplateVariables(template: string, context: TemplateC CUE_GH_BASE_BRANCH: context.cue?.ghBaseBranch || '', CUE_GH_ASSIGNEES: context.cue?.ghAssignees || '', CUE_GH_MERGED_AT: context.cue?.ghMergedAt || '', + CUE_CLI_PROMPT: context.cue?.cliPrompt || '', }; // Perform case-insensitive replacement