Skip to content

Commit cf9cc1a

Browse files
committed
refactor(studio-bridge): reorganize CLI into declarative grouped commands
Replace 13 flat CLI commands with a declarative defineCommand() system that drives CLI, MCP, and terminal from a single source per command. Commands are now grouped (console, explorer, viewport, process, plugin) and co-located with their Luau action files, which are pushed to the plugin dynamically over the wire instead of bundled statically.
1 parent 31e7201 commit cf9cc1a

File tree

87 files changed

+5782
-2044
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

87 files changed

+5782
-2044
lines changed

tools/studio-bridge/README.md

Lines changed: 141 additions & 128 deletions
Large diffs are not rendered by default.

tools/studio-bridge/src/bridge/bridge-connection.ts

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,9 @@ import type {
2626
import type { SessionInfo, SessionContext, InstanceInfo } from './types.js';
2727
import { SessionNotFoundError, ContextNotFoundError } from './types.js';
2828
import type { ServerMessage } from '../server/web-socket-protocol.js';
29+
import { pushActionsToSessionAsync } from './internal/action-pusher.js';
30+
import { loadActionSourcesAsync, type ActionSource } from '../commands/framework/action-loader.js';
31+
import { OutputHelper } from '@quenty/cli-output-helpers';
2932

3033
// ---------------------------------------------------------------------------
3134
// Public types
@@ -118,6 +121,9 @@ export class BridgeConnection extends EventEmitter {
118121
private _hostSessions: Map<string, BridgeSession> = new Map();
119122
private _hostHandles: Map<string, HostTransportHandle> = new Map();
120123

124+
/** Cached action sources loaded once for pushing to new plugin sessions. */
125+
private _actionSources: ActionSource[] | undefined;
126+
121127
// Client mode internals
122128
private _client: BridgeClient | undefined;
123129

@@ -569,6 +575,15 @@ export class BridgeConnection extends EventEmitter {
569575
const handle = new HostTransportHandle(info.sessionId, this._host!);
570576
this._hostHandles.set(info.sessionId, handle);
571577
this._tracker!.addSession(info.sessionId, sessionInfo, handle);
578+
579+
// Push action modules to the newly connected plugin (fire and forget)
580+
if (info.capabilities.includes('registerAction')) {
581+
this._pushActionsAsync(info.sessionId).catch((err) => {
582+
OutputHelper.verbose(
583+
`[BridgeConnection] Failed to push actions to ${info.sessionId}: ${err instanceof Error ? err.message : String(err)}`,
584+
);
585+
});
586+
}
572587
});
573588

574589
this._host.on('plugin-disconnected', (sessionId: string) => {
@@ -743,4 +758,41 @@ export class BridgeConnection extends EventEmitter {
743758
this._idleTimer = undefined;
744759
}
745760
}
761+
762+
// -----------------------------------------------------------------------
763+
// Private: Dynamic action push
764+
// -----------------------------------------------------------------------
765+
766+
/**
767+
* Load action sources (cached) and push them to the given plugin session.
768+
*/
769+
private async _pushActionsAsync(sessionId: string): Promise<void> {
770+
if (!this._host) return;
771+
772+
// Lazy-load and cache action sources
773+
if (!this._actionSources) {
774+
this._actionSources = await loadActionSourcesAsync();
775+
}
776+
777+
if (this._actionSources.length === 0) return;
778+
779+
const results = await pushActionsToSessionAsync(
780+
this._host,
781+
sessionId,
782+
this._actionSources,
783+
);
784+
785+
const succeeded = results.filter((r) => r.success).length;
786+
const failed = results.filter((r) => !r.success).length;
787+
788+
if (failed > 0) {
789+
OutputHelper.verbose(
790+
`[BridgeConnection] Pushed ${succeeded}/${results.length} actions to ${sessionId} (${failed} failed)`,
791+
);
792+
} else {
793+
OutputHelper.verbose(
794+
`[BridgeConnection] Pushed ${succeeded} action(s) to ${sessionId}`,
795+
);
796+
}
797+
}
746798
}
Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
/**
2+
* Pushes co-located `.luau` action modules to a connected plugin session.
3+
* Called after a plugin registers with the bridge to dynamically install
4+
* all action handlers.
5+
*
6+
* Uses the `registerAction` request/response protocol message. Each action
7+
* is sent individually and the result is awaited before proceeding to the
8+
* next, ensuring deterministic registration order.
9+
*/
10+
11+
import type { ActionSource } from '../../commands/framework/action-loader.js';
12+
import type { BridgeHost } from './bridge-host.js';
13+
import { OutputHelper } from '@quenty/cli-output-helpers';
14+
15+
// ---------------------------------------------------------------------------
16+
// Types
17+
// ---------------------------------------------------------------------------
18+
19+
export interface ActionPushResult {
20+
name: string;
21+
success: boolean;
22+
error?: string;
23+
}
24+
25+
// ---------------------------------------------------------------------------
26+
// Push logic
27+
// ---------------------------------------------------------------------------
28+
29+
/**
30+
* Push all action sources to a specific plugin session. Returns results
31+
* for each action (success or failure). Failures are logged but do not
32+
* abort the remaining pushes.
33+
*/
34+
export async function pushActionsToSessionAsync(
35+
host: BridgeHost,
36+
sessionId: string,
37+
actions: ActionSource[],
38+
): Promise<ActionPushResult[]> {
39+
const results: ActionPushResult[] = [];
40+
41+
for (const action of actions) {
42+
try {
43+
const response = await host.sendToPluginAsync<{
44+
type: string;
45+
payload: {
46+
name: string;
47+
success: boolean;
48+
error?: string;
49+
};
50+
}>(
51+
sessionId,
52+
{
53+
type: 'registerAction',
54+
sessionId,
55+
requestId: `register-${action.name}-${Date.now()}`,
56+
payload: {
57+
name: action.name,
58+
source: action.source,
59+
},
60+
},
61+
10_000,
62+
);
63+
64+
if (response.payload?.success) {
65+
results.push({ name: action.name, success: true });
66+
} else {
67+
const error = response.payload?.error ?? 'Unknown registration error';
68+
OutputHelper.verbose(
69+
`[ActionPusher] Failed to register action '${action.name}' on session ${sessionId}: ${error}`,
70+
);
71+
results.push({ name: action.name, success: false, error });
72+
}
73+
} catch (err) {
74+
const error = err instanceof Error ? err.message : String(err);
75+
OutputHelper.verbose(
76+
`[ActionPusher] Error pushing action '${action.name}' to session ${sessionId}: ${error}`,
77+
);
78+
results.push({ name: action.name, success: false, error });
79+
}
80+
}
81+
82+
return results;
83+
}
Lines changed: 210 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,210 @@
1+
/**
2+
* Unit tests for the CLI command adapter.
3+
*/
4+
5+
import { describe, it, expect, vi } from 'vitest';
6+
import { buildYargsCommand } from './cli-command-adapter.js';
7+
import { defineCommand, type CommandDefinition } from '../../commands/framework/define-command.js';
8+
import { arg } from '../../commands/framework/arg-builder.js';
9+
10+
// ---------------------------------------------------------------------------
11+
// Helpers
12+
// ---------------------------------------------------------------------------
13+
14+
function sessionCommand(
15+
overrides: Partial<{ name: string; safety: 'read' | 'mutate' | 'none' }> = {},
16+
): CommandDefinition {
17+
return defineCommand({
18+
group: 'console',
19+
name: overrides.name ?? 'exec',
20+
description: 'Execute code',
21+
category: 'execution',
22+
safety: overrides.safety ?? 'mutate',
23+
scope: 'session',
24+
args: {
25+
code: arg.positional({ description: 'Luau source code' }),
26+
timeout: arg.option({ description: 'Timeout', type: 'number', alias: 'T' }),
27+
},
28+
handler: async (_session, _args) => ({ success: true }),
29+
});
30+
}
31+
32+
function standaloneCommand(): CommandDefinition {
33+
return defineCommand({
34+
group: null,
35+
name: 'serve',
36+
description: 'Start the bridge server',
37+
category: 'infrastructure',
38+
safety: 'none',
39+
scope: 'standalone',
40+
args: {
41+
port: arg.option({ description: 'Port number', type: 'number', default: 38741 }),
42+
},
43+
handler: async () => ({ started: true }),
44+
});
45+
}
46+
47+
function connectionCommand(): CommandDefinition {
48+
return defineCommand({
49+
group: 'process',
50+
name: 'list',
51+
description: 'List sessions',
52+
category: 'infrastructure',
53+
safety: 'read',
54+
scope: 'connection',
55+
args: {},
56+
handler: async () => ({ sessions: [] }),
57+
});
58+
}
59+
60+
/** Mock yargs Argv object that records calls. */
61+
function createMockYargs() {
62+
const positionals: Record<string, any> = {};
63+
const options: Record<string, any> = {};
64+
65+
const mock: any = {
66+
positionals,
67+
options,
68+
positional: vi.fn((name: string, opts: any) => {
69+
positionals[name] = opts;
70+
return mock;
71+
}),
72+
option: vi.fn((name: string, opts: any) => {
73+
options[name] = opts;
74+
return mock;
75+
}),
76+
demandCommand: vi.fn(() => mock),
77+
};
78+
79+
return mock;
80+
}
81+
82+
// ---------------------------------------------------------------------------
83+
// Tests
84+
// ---------------------------------------------------------------------------
85+
86+
describe('buildYargsCommand', () => {
87+
describe('command string', () => {
88+
it('includes positional args in command string', () => {
89+
const module = buildYargsCommand(sessionCommand());
90+
expect(module.command).toBe('exec <code>');
91+
});
92+
93+
it('produces simple command string with no positionals', () => {
94+
const module = buildYargsCommand(standaloneCommand());
95+
expect(module.command).toBe('serve');
96+
});
97+
98+
it('uses command description', () => {
99+
const module = buildYargsCommand(sessionCommand());
100+
expect(module.describe).toBe('Execute code');
101+
});
102+
});
103+
104+
describe('builder — arg registration', () => {
105+
it('registers positional args', () => {
106+
const module = buildYargsCommand(sessionCommand());
107+
const yargs = createMockYargs();
108+
(module.builder as any)(yargs);
109+
110+
expect(yargs.positionals.code).toBeDefined();
111+
expect(yargs.positionals.code.describe).toBe('Luau source code');
112+
expect(yargs.positionals.code.type).toBe('string');
113+
expect(yargs.positionals.code.demandOption).toBe(true);
114+
});
115+
116+
it('registers command-specific options', () => {
117+
const module = buildYargsCommand(sessionCommand());
118+
const yargs = createMockYargs();
119+
(module.builder as any)(yargs);
120+
121+
expect(yargs.options.timeout).toBeDefined();
122+
expect(yargs.options.timeout.describe).toBe('Timeout');
123+
expect(yargs.options.timeout.type).toBe('number');
124+
expect(yargs.options.timeout.alias).toBe('T');
125+
});
126+
});
127+
128+
describe('builder — universal args', () => {
129+
it('injects --target and --context for session-scoped commands', () => {
130+
const module = buildYargsCommand(sessionCommand());
131+
const yargs = createMockYargs();
132+
(module.builder as any)(yargs);
133+
134+
expect(yargs.options.target).toBeDefined();
135+
expect(yargs.options.target.alias).toBe('t');
136+
expect(yargs.options.context).toBeDefined();
137+
});
138+
139+
it('injects --target and --context for connection-scoped commands', () => {
140+
const module = buildYargsCommand(connectionCommand());
141+
const yargs = createMockYargs();
142+
(module.builder as any)(yargs);
143+
144+
expect(yargs.options.target).toBeDefined();
145+
expect(yargs.options.context).toBeDefined();
146+
});
147+
148+
it('does not inject --target for standalone commands', () => {
149+
const module = buildYargsCommand(standaloneCommand());
150+
const yargs = createMockYargs();
151+
(module.builder as any)(yargs);
152+
153+
expect(yargs.options.target).toBeUndefined();
154+
expect(yargs.options.context).toBeUndefined();
155+
});
156+
157+
it('always injects --format, --output, --open', () => {
158+
const module = buildYargsCommand(standaloneCommand());
159+
const yargs = createMockYargs();
160+
(module.builder as any)(yargs);
161+
162+
expect(yargs.options.format).toBeDefined();
163+
expect(yargs.options.output).toBeDefined();
164+
expect(yargs.options.open).toBeDefined();
165+
});
166+
167+
it('injects --watch and --interval for read-safety commands', () => {
168+
const module = buildYargsCommand(connectionCommand()); // safety: 'read'
169+
const yargs = createMockYargs();
170+
(module.builder as any)(yargs);
171+
172+
expect(yargs.options.watch).toBeDefined();
173+
expect(yargs.options.watch.alias).toBe('w');
174+
expect(yargs.options.interval).toBeDefined();
175+
expect(yargs.options.interval.default).toBe(1000);
176+
});
177+
178+
it('does not inject --watch for mutate-safety commands', () => {
179+
const module = buildYargsCommand(sessionCommand({ safety: 'mutate' }));
180+
const yargs = createMockYargs();
181+
(module.builder as any)(yargs);
182+
183+
expect(yargs.options.watch).toBeUndefined();
184+
expect(yargs.options.interval).toBeUndefined();
185+
});
186+
});
187+
188+
describe('handler — standalone', () => {
189+
it('calls standalone handler with extracted args', async () => {
190+
const handler = vi.fn().mockResolvedValue({ started: true });
191+
const cmd = defineCommand({
192+
group: null,
193+
name: 'serve',
194+
description: 'Start server',
195+
category: 'infrastructure',
196+
safety: 'none',
197+
scope: 'standalone',
198+
args: {
199+
port: arg.option({ description: 'Port', type: 'number', default: 38741 }),
200+
},
201+
handler,
202+
});
203+
204+
const module = buildYargsCommand(cmd);
205+
await (module.handler as any)({ port: 9999, verbose: false, timeout: 120000 });
206+
207+
expect(handler).toHaveBeenCalledWith({ port: 9999 });
208+
});
209+
});
210+
});

0 commit comments

Comments
 (0)