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
89 changes: 89 additions & 0 deletions src/__tests__/main/web-server/handlers/messageHandlers.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,7 @@ function createMockCallbacks(): MessageHandlerCallbacks {
mergeContext: vi.fn().mockResolvedValue(true),
transferContext: vi.fn().mockResolvedValue(true),
summarizeContext: vi.fn().mockResolvedValue(true),
createGist: vi.fn().mockResolvedValue({ success: true, gistUrl: 'https://gist.example' }),
getCueSubscriptions: vi.fn().mockResolvedValue([]),
toggleCueSubscription: vi.fn().mockResolvedValue(true),
getCueActivity: vi.fn().mockResolvedValue([]),
Expand Down Expand Up @@ -1674,4 +1675,92 @@ describe('WebSocketMessageHandler', () => {
expect(callbacks.triggerCueSubscription).not.toHaveBeenCalled();
});
});

describe('Create Gist', () => {
it('replies with create_gist_result on success', async () => {
handler.handleMessage(client, {
type: 'create_gist',
sessionId: 'session-1',
description: 'My gist',
isPublic: false,
});

await vi.waitFor(() => {
expect(callbacks.createGist).toHaveBeenCalledWith('session-1', 'My gist', false);
});

const response = JSON.parse((client.socket.send as any).mock.calls[0][0]);
expect(response.type).toBe('create_gist_result');
expect(response.success).toBe(true);
expect(response.gistUrl).toBe('https://gist.example');
});

it('defaults description to "" and isPublic to false when omitted', async () => {
handler.handleMessage(client, {
type: 'create_gist',
sessionId: 'session-1',
});

await vi.waitFor(() => {
expect(callbacks.createGist).toHaveBeenCalledWith('session-1', '', false);
});
});

it('replies with create_gist_result (not error) when sessionId is missing', () => {
handler.handleMessage(client, { type: 'create_gist' });

const response = JSON.parse((client.socket.send as any).mock.calls[0][0]);
expect(response.type).toBe('create_gist_result');
expect(response.success).toBe(false);
expect(response.error).toContain('sessionId');
expect(callbacks.createGist).not.toHaveBeenCalled();
});

it('rejects non-boolean isPublic to prevent private→public leaks', () => {
handler.handleMessage(client, {
type: 'create_gist',
sessionId: 'session-1',
isPublic: 'false',
});

const response = JSON.parse((client.socket.send as any).mock.calls[0][0]);
expect(response.type).toBe('create_gist_result');
expect(response.success).toBe(false);
expect(response.error).toContain('isPublic');
expect(callbacks.createGist).not.toHaveBeenCalled();
});

it('surfaces rejected callback errors as create_gist_result', async () => {
(callbacks.createGist as any).mockRejectedValue(new Error('boom'));

handler.handleMessage(client, {
type: 'create_gist',
sessionId: 'session-1',
});

await vi.waitFor(() => {
expect(client.socket.send).toHaveBeenCalled();
});

const response = JSON.parse((client.socket.send as any).mock.calls[0][0]);
expect(response.type).toBe('create_gist_result');
expect(response.success).toBe(false);
expect(response.error).toContain('boom');
});

it('replies with create_gist_result when createGist callback is unconfigured', () => {
callbacks.createGist = undefined;
handler.setCallbacks(callbacks);

handler.handleMessage(client, {
type: 'create_gist',
sessionId: 'session-1',
});

const response = JSON.parse((client.socket.send as any).mock.calls[0][0]);
expect(response.type).toBe('create_gist_result');
expect(response.success).toBe(false);
expect(response.error).toContain('not configured');
});
});
});
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 @@ -69,6 +69,7 @@ vi.mock('../../../main/web-server/WebServer', () => {
setMergeContextCallback = vi.fn();
setTransferContextCallback = vi.fn();
setSummarizeContextCallback = vi.fn();
setCreateGistCallback = vi.fn();
setGetCueSubscriptionsCallback = vi.fn();
setToggleCueSubscriptionCallback = vi.fn();
setGetCueActivityCallback = vi.fn();
Expand Down
4 changes: 4 additions & 0 deletions src/__tests__/renderer/hooks/useRemoteIntegration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -177,6 +177,10 @@ describe('useRemoteIntegration', () => {
return () => {};
}),
sendRemoteGetGitDiffResponse: vi.fn(),
onRemoteCreateGist: vi.fn().mockImplementation(() => {
return () => {};
}),
sendRemoteCreateGistResponse: vi.fn(),
onRemoteTriggerCueSubscription: vi.fn().mockImplementation(() => {
return () => {};
}),
Expand Down
85 changes: 85 additions & 0 deletions src/cli/commands/gist.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
// Gist create - publish an agent's session transcript to a GitHub gist.
// Routes through the running Maestro desktop app (which holds live tab
// transcripts) and reuses the existing `gh gist create` IPC handler.

import { resolveAgentId } from '../services/storage';
import { withMaestroClient } from '../services/maestro-client';

interface GistCreateOptions {
description?: string;
public?: boolean;
}

interface GistCreateResponse {
success: boolean;
agentId?: string;
gistUrl?: string;
error?: string;
code?: string;
}

function emitErrorJson(error: string, code: string): void {
const payload: GistCreateResponse = { success: false, error, code };
console.log(JSON.stringify(payload, null, 2));
}

export async function gistCreate(agentIdArg: string, options: GistCreateOptions): Promise<void> {
let agentId: string;
try {
agentId = resolveAgentId(agentIdArg);
} catch (error) {
const msg = error instanceof Error ? error.message : 'Unknown error';
emitErrorJson(msg, 'AGENT_NOT_FOUND');
process.exit(1);
}

const description = options.description ?? '';
const isPublic = Boolean(options.public);

try {
const result = await withMaestroClient((client) =>
client.sendCommand<{
success: boolean;
gistUrl?: string;
error?: string;
}>(
{
type: 'create_gist',
sessionId: agentId,
description,
isPublic,
},
'create_gist_result',
60000
)
);

if (!result.success || !result.gistUrl) {
emitErrorJson(result.error ?? 'Failed to create gist', 'GIST_CREATE_FAILED');
process.exit(1);
}

const response: GistCreateResponse = {
success: true,
agentId,
gistUrl: result.gistUrl,
};
console.log(JSON.stringify(response, null, 2));
} catch (error) {
const msg = error instanceof Error ? error.message : String(error);
const lower = msg.toLowerCase();
if (
lower.includes('econnrefused') ||
lower.includes('connection refused') ||
lower.includes('websocket') ||
lower.includes('enotfound') ||
lower.includes('etimedout') ||
lower.includes('not running')
) {
emitErrorJson('Maestro desktop is not running or not reachable', 'MAESTRO_NOT_RUNNING');
} else {
emitErrorJson(`Gist creation failed: ${msg}`, 'GIST_CREATE_FAILED');
}
process.exit(1);
}
}
15 changes: 15 additions & 0 deletions src/cli/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ import {
settingsAgentReset,
} from './commands/settings-agent';
import { promptsGet, promptsList } from './commands/prompts-get';
import { gistCreate } from './commands/gist';
import { notifyToast } from './commands/notify-toast';
import { notifyFlash } from './commands/notify-flash';

Expand Down Expand Up @@ -419,6 +420,20 @@ prompts
.option('--json', 'Output as JSON object with metadata + content')
.action(promptsGet);

// Gist commands — publish agent session transcripts to GitHub gists via the
// running Maestro desktop app. Grouped as a subcommand so we can add more gist
// operations (list, show, delete, etc.) later.
const gist = program.command('gist').description('Publish session context to GitHub gists');

gist
.command('create <agent-id>')
.description(
"Publish an agent's session transcript as a GitHub gist (requires running Maestro app)"
)
.option('-d, --description <text>', 'Gist description')
.option('-p, --public', 'Create a public gist (default: private)')
.action(gistCreate);

// Notify commands — surface notifications in the Maestro desktop app
const notify = program
.command('notify')
Expand Down
45 changes: 45 additions & 0 deletions src/main/preload/process.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1143,6 +1143,51 @@ export function createProcessApi() {
ipcRenderer.send(responseChannel, success);
},

/**
* Subscribe to remote create-gist requests from the web/CLI interface.
* Uses request-response pattern with a unique responseChannel. Ack a
* structured failure before rethrowing synchronous callback errors so
* the CLI doesn't wait for the 60s response timeout.
*/
onRemoteCreateGist: (
callback: (
sessionId: string,
description: string,
isPublic: boolean,
responseChannel: string
) => void
): (() => void) => {
const handler = (
_: unknown,
sessionId: string,
description: string,
isPublic: boolean,
responseChannel: string
) => {
try {
callback(sessionId, description, isPublic, responseChannel);
} catch (error) {
ipcRenderer.send(responseChannel, {
success: false,
error: error instanceof Error ? error.message : String(error),
});
throw error;
}
};
ipcRenderer.on('remote:createGist', handler);
return () => ipcRenderer.removeListener('remote:createGist', handler);
},

/**
* Send response for remote create-gist
*/
sendRemoteCreateGistResponse: (
responseChannel: string,
result: { success: boolean; gistUrl?: string; error?: string }
): void => {
ipcRenderer.send(responseChannel, result);
},

/**
* Subscribe to remote get Cue subscriptions from web interface
*/
Expand Down
7 changes: 7 additions & 0 deletions src/main/web-server/WebServer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,7 @@ import type {
MergeContextCallback,
TransferContextCallback,
SummarizeContextCallback,
CreateGistCallback,
GetCueSubscriptionsCallback,
ToggleCueSubscriptionCallback,
TriggerCueSubscriptionCallback,
Expand Down Expand Up @@ -556,6 +557,10 @@ export class WebServer {
this.callbackRegistry.setSummarizeContextCallback(callback);
}

setCreateGistCallback(callback: CreateGistCallback): void {
this.callbackRegistry.setCreateGistCallback(callback);
}

setGetCueSubscriptionsCallback(callback: GetCueSubscriptionsCallback): void {
this.callbackRegistry.setGetCueSubscriptionsCallback(callback);
}
Expand Down Expand Up @@ -818,6 +823,8 @@ export class WebServer {
this.callbackRegistry.transferContext(sourceSessionId, targetSessionId),
summarizeContext: async (sessionId: string) =>
this.callbackRegistry.summarizeContext(sessionId),
createGist: async (sessionId: string, description: string, isPublic: boolean) =>
this.callbackRegistry.createGist(sessionId, description, isPublic),
getCueSubscriptions: async (sessionId?: string) =>
this.callbackRegistry.getCueSubscriptions(sessionId),
toggleCueSubscription: async (subscriptionId: string, enabled: boolean) =>
Expand Down
58 changes: 58 additions & 0 deletions src/main/web-server/handlers/messageHandlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -201,6 +201,11 @@ export interface MessageHandlerCallbacks {
mergeContext: (sourceSessionId: string, targetSessionId: string) => Promise<boolean>;
transferContext: (sourceSessionId: string, targetSessionId: string) => Promise<boolean>;
summarizeContext: (sessionId: string) => Promise<boolean>;
createGist: (
sessionId: string,
description: string,
isPublic: boolean
) => Promise<{ success: boolean; gistUrl?: string; error?: string }>;
getCueSubscriptions: (sessionId?: string) => Promise<CueSubscriptionInfo[]>;
toggleCueSubscription: (subscriptionId: string, enabled: boolean) => Promise<boolean>;
getCueActivity: (sessionId?: string, limit?: number) => Promise<CueActivityEntry[]>;
Expand Down Expand Up @@ -454,6 +459,10 @@ export class WebSocketMessageHandler {
this.handleSummarizeContext(client, message);
break;

case 'create_gist':
this.handleCreateGist(client, message);
break;

case 'get_cue_subscriptions':
this.handleGetCueSubscriptions(client, message);
break;
Expand Down Expand Up @@ -2605,6 +2614,55 @@ export class WebSocketMessageHandler {
});
}

/**
* Handle create_gist message - publish a session's transcript to a GitHub gist.
* Always replies with `create_gist_result` (even on failure) so waiting
* clients don't hang until their request timeout.
*/
private handleCreateGist(client: WebClient, message: WebClientMessage): void {
const reply = (result: { success: boolean; gistUrl?: string; error?: string }) => {
this.send(client, {
type: 'create_gist_result',
...result,
requestId: message.requestId,
});
};

const sessionId = message.sessionId;
if (typeof sessionId !== 'string' || !sessionId) {
reply({ success: false, error: 'Missing sessionId' });
return;
}

// Strict validation — avoid truthy coercion so a string like "false"
// cannot flip a private gist to public.
if (message.description !== undefined && typeof message.description !== 'string') {
reply({ success: false, error: 'description must be a string when provided' });
return;
}
if (message.isPublic !== undefined && typeof message.isPublic !== 'boolean') {
reply({ success: false, error: 'isPublic must be a boolean when provided' });
return;
}
const description = message.description ?? '';
const isPublic = message.isPublic ?? false;

if (!this.callbacks.createGist) {
reply({ success: false, error: 'Gist creation not configured' });
return;
}

this.callbacks
.createGist(sessionId, description, isPublic)
Comment on lines +2654 to +2656
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 error.message access without instanceof guard

If the createGist callback rejects with a non-Error (a string, a plain object, etc.), error.message evaluates to undefined and the client receives "Failed to create gist: undefined". Guarding with error instanceof Error ? error.message : String(error) is the convention used throughout this codebase (see gist.ts line 675 and web-server-factory.ts error paths) and avoids the unhelpful message.

Suggested change
this.callbacks
.createGist(sessionId, description, isPublic)
.catch((error: unknown) => {
const msg = error instanceof Error ? error.message : String(error);
this.sendError(client, `Failed to create gist: ${msg}`);
});

.then((result) => {
reply(result);
})
.catch((error: unknown) => {
const msg = error instanceof Error ? error.message : String(error);
reply({ success: false, error: `Failed to create gist: ${msg}` });
});
}

/**
* Handle get_cue_subscriptions message - fetch Cue subscriptions
*/
Expand Down
Loading