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
7 changes: 7 additions & 0 deletions scripts/setup-git-hooks.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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');

Expand Down
2 changes: 1 addition & 1 deletion src/__tests__/main/web-server/WebServer.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'),
'<script type="module" src="./assets/main.js"></script>'
Expand Down
1 change: 1 addition & 0 deletions src/__tests__/main/web-server/web-server-factory.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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(
<TriggerDrawer isOpen={true} onClose={() => {}} 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"', () => {
Expand Down
5 changes: 3 additions & 2 deletions src/__tests__/renderer/hooks/cue/usePipelineState.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -144,9 +144,9 @@ function makePipeline(overrides?: Partial<CuePipeline>): 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');
Expand All @@ -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');
});
});

Expand Down
10 changes: 10 additions & 0 deletions src/__tests__/renderer/hooks/useRemoteIntegration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -188,6 +188,10 @@ describe('useRemoteIntegration', () => {
return () => {};
}),
sendRemoteGetGitDiffResponse: vi.fn(),
onRemoteTriggerCueSubscription: vi.fn().mockImplementation(() => {
return () => {};
}),
sendRemoteTriggerCueSubscriptionResponse: vi.fn(),
};

const mockLive = {
Expand Down Expand Up @@ -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;
Expand All @@ -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,
};
});

Expand Down
69 changes: 69 additions & 0 deletions src/cli/commands/cue-trigger.ts
Original file line number Diff line number Diff line change
@@ -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<void> {
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);
}
}
11 changes: 11 additions & 0 deletions src/cli/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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 <subscription-name>')
.description('Manually trigger a Cue subscription by name')
.option('-p, --prompt <text>', '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')
Expand Down
2 changes: 2 additions & 0 deletions src/main/cue/config/cue-config-validator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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' &&
Expand Down
10 changes: 6 additions & 4 deletions src/main/cue/cue-dispatch-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,8 @@ export interface CueDispatchService {
sub: CueSubscription,
event: CueEvent,
sourceSessionName: string,
chainDepth?: number
chainDepth?: number,
promptOverride?: string
): void;
}

Expand All @@ -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(', ');
Expand Down Expand Up @@ -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',
Expand All @@ -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;
Expand Down
21 changes: 17 additions & 4 deletions src/main/cue/cue-engine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
}
Expand Down
5 changes: 5 additions & 0 deletions src/main/cue/cue-template-context-builder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,11 @@ function buildGitHubContext(event: CueEvent): Record<string, string> {
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 ──────────────────────────────────────────────────────────────

/**
Expand Down
5 changes: 3 additions & 2 deletions src/main/cue/triggers/cue-trigger-source-registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
4 changes: 2 additions & 2 deletions src/main/ipc/handlers/cue.ts
Original file line number Diff line number Diff line change
Expand Up @@ -142,8 +142,8 @@ export function registerCueHandlers(deps: CueHandlerDependencies): void {
'cue:triggerSubscription',
withIpcErrorLogging(
handlerOpts('triggerSubscription'),
async (options: { subscriptionName: string }): Promise<boolean> => {
return requireEngine().triggerSubscription(options.subscriptionName);
async (options: { subscriptionName: string; prompt?: string }): Promise<boolean> => {
return requireEngine().triggerSubscription(options.subscriptionName, options.prompt);
}
)
);
Expand Down
6 changes: 3 additions & 3 deletions src/main/preload/cue.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,9 +59,9 @@ export function createCueApi() {
// Stop all running Cue executions
stopAll: (): Promise<void> => ipcRenderer.invoke('cue:stopAll'),

// Manually trigger a subscription by name (Run Now)
triggerSubscription: (subscriptionName: string): Promise<boolean> =>
ipcRenderer.invoke('cue:triggerSubscription', { subscriptionName }),
// Manually trigger a subscription by name (Run Now), with optional prompt override
triggerSubscription: (subscriptionName: string, prompt?: string): Promise<boolean> =>
ipcRenderer.invoke('cue:triggerSubscription', { subscriptionName, prompt }),

// Get queue status per session
getQueueStatus: (): Promise<Record<string, number>> => ipcRenderer.invoke('cue:getQueueStatus'),
Expand Down
35 changes: 35 additions & 0 deletions src/main/preload/process.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
*/
Expand Down
9 changes: 8 additions & 1 deletion src/main/web-server/WebServer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,7 @@ import type {
SummarizeContextCallback,
GetCueSubscriptionsCallback,
ToggleCueSubscriptionCallback,
TriggerCueSubscriptionCallback,
GetCueActivityCallback,
CueActivityEntry,
CueSubscriptionInfo,
Expand Down Expand Up @@ -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') {
Expand Down Expand Up @@ -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);
}
Expand Down
Loading