Provide an interactive UI to select one or more plugins, then immediately launch copilot with that selection (equivalent to passing multiple --load ... flags).
This is a convenience workflow on top of the existing pipeline:
scanAllPlugins() → resolve PluginInfo[] → translatePlugins() → executeCopilot().
construct operator [-- copilot-args...]
# examples
construct operator
construct operator -- --continue
construct operator -- "fix the failing tests"No operator-specific flags for v1.
- Selection is always persisted to
.construct.json(matching existing behavior when using--load). fzfis a hard dependency for this subcommand (construct operator).
- Collect all available plugins using the existing scanner.
- Show an interactive multi-select picker (
fzf --multi). - User selects 0..N plugins.
- 0 selected (or user cancels): exit 0 and do not modify config.
- If selection is non-empty:
- Persist selection to
.construct.jsonasenabledPlugins+lastUsed. - Run the normal translation +
copilotspawn with the selected plugins.
- Persist selection to
Use fzf via Bun.spawnSync so it can control the terminal.
- Input to
fzf: one plugin per line, formatted as:<pluginId>\t<description>- Example:
tmux@scaryrawr-plugins\tTerminal multiplexer helpers
- In the list UI, show only the pluginId (hide the description column).
- In the preview pane, show the plugin description.
Suggested fzf args (POSIX shell):
--multi--prompt "Plugins> "--header "Select plugins to enable (TAB to mark, ENTER to run)"--delimiter "\t"--with-nth 1--preview "echo {2}"- (optional)
--preview-window "wrap"
Notes:
- Sanitize descriptions to be single-line (replace newlines/tabs with spaces) so the TSV format stays intact.
Interpretation of fzf result:
- exit code 0: selection is stdout, newline-separated lines (including both columns if not configured to print only field 1).
- Parse selection by splitting lines and taking field 1 (pluginId) before the first tab.
- non-zero exit code (typically 130 on ESC/Ctrl-C): treat as “cancel” and exit 0.
The fzf preview should display not just the description, but also the list of skills, MCP servers, and agents that will be added when a plugin is selected.
Since fzf's --preview runs an external shell command, we need to encode the component information in the fzf input so it can be extracted and formatted by the preview command.
Option chosen: JSON-encoded components in a hidden field
Encode the full preview data as a base64-encoded JSON string in a third TSV column. The preview command decodes and formats it.
fzf input format (3 columns, tab-separated):
<pluginId>\t<description>\t<base64-encoded-preview-json>
Preview JSON structure:
{
"description": "Terminal multiplexer helpers",
"skills": ["tmux-session", "tmux-window"],
"mcpServers": ["tmux-server"],
"agents": ["tmux-agent"]
}fzf args update:
--with-nth 1(unchanged - only show pluginId in list)--preview "echo {3} | base64 -d | jq -r '\"Description:\\n \" + .description + \"\\n\\nSkills:\\n \" + (if (.skills | length) > 0 then (.skills | join(\"\\n \")) else \"(none)\" end) + \"\\n\\nMCP Servers:\\n \" + (if (.mcpServers | length) > 0 then (.mcpServers | join(\"\\n \")) else \"(none)\" end) + \"\\n\\nAgents:\\n \" + (if (.agents | length) > 0 then (.agents | join(\"\\n \")) else \"(none)\" end)'"
Alternative (simpler, if jq is not guaranteed): Use a shell script that decodes and formats with basic tools:
--preview "echo {3} | base64 -d"And pre-format the JSON as plain text before encoding.
Recommended: Pre-formatted plain text encoding
Simpler approach - encode pre-formatted plain text (not JSON) so no jq dependency:
<pluginId>\t<description>\t<base64-encoded-preview-text>
Preview text format (before base64 encoding):
Description:
Terminal multiplexer helpers
Skills:
tmux-session
tmux-window
MCP Servers:
tmux-server
Agents:
tmux-agent
fzf preview command:
--preview "echo {3} | base64 -d"- Add
buildPreviewTextfunction:
function buildPreviewText(plugin: PluginInfo): string {
const lines: string[] = [];
lines.push("Description:");
lines.push(` ${plugin.description || "(no description)"}`);
lines.push("");
const skills = plugin.components
.filter(c => c.type === 'skill')
.flatMap(c => {
// For skills, scan the actual skill subdirectories
// The component.name is 'skills' but we want individual skill names
return getSkillNames(c.path);
});
lines.push("Skills:");
lines.push(skills.length > 0 ? skills.map(s => ` ${s}`).join("\n") : " (none)");
lines.push("");
const mcpServers = plugin.components
.filter(c => c.type === 'mcp')
.map(c => getMcpServerNames(c.path))
.flat();
lines.push("MCP Servers:");
lines.push(mcpServers.length > 0 ? mcpServers.map(s => ` ${s}`).join("\n") : " (none)");
lines.push("");
const agents = plugin.components
.filter(c => c.type === 'agent')
.map(c => c.name);
lines.push("Agents:");
lines.push(agents.length > 0 ? agents.map(a => ` ${a}`).join("\n") : " (none)");
return lines.join("\n");
}- Update
buildFzfInputto include preview data:
function buildFzfInput(plugins: PluginInfo[]): string {
return plugins
.map((plugin) => {
const previewText = buildPreviewText(plugin);
const previewBase64 = Buffer.from(previewText).toString('base64');
return `${plugin.name}\t${sanitizeForTsv(plugin.description)}\t${previewBase64}`;
})
.join("\n");
}- Update fzf args:
const fzfArgs = [
"--multi",
"--prompt", "Plugins> ",
"--header", "Select plugins to enable (TAB to mark, ENTER to run)",
"--delimiter", "\t",
"--with-nth", "1",
"--preview", "echo {3} | base64 -d",
"--preview-window", "wrap",
];- Add helper functions for extracting names from components:
import { Glob } from "bun";
import { basename } from "node:path";
function getSkillNamesSync(skillsDir: string): string[] {
try {
const glob = new Glob("*/SKILL.md");
const files = Array.from(glob.scanSync({ cwd: skillsDir, absolute: false }));
return files.map(f => f.split('/')[0]!);
} catch {
return [];
}
}
function getMcpServerNamesSync(mcpJsonPath: string): string[] {
try {
const content = require('fs').readFileSync(mcpJsonPath, 'utf-8');
const mcpConfig = JSON.parse(content);
if (mcpConfig.mcpServers && typeof mcpConfig.mcpServers === 'object') {
return Object.keys(mcpConfig.mcpServers);
}
return [];
} catch {
return [];
}
}When a user hovers over tmux@scaryrawr-plugins:
Description:
Terminal multiplexer session management
Skills:
tmux-session
tmux-window
MCP Servers:
tmux-mcp
Agents:
tmux-helper
When a plugin has no components of a type:
Description:
Simple utility plugin
Skills:
(none)
MCP Servers:
utility-server
Agents:
(none)
- Pre-select last used plugins from
.construct.json(fzf supports various bindings, but preselecting is non-trivial; treat as future work).
- Entry point:
index.ts - CLI parsing:
src/cli.ts(currently option-based, no subcommands except yargs completion) - Plugin discovery:
src/scanner.ts - Config:
src/config.ts - Translation/execution:
src/translator.ts,src/executor.ts
Update src/cli.ts to support the operator subcommand while keeping existing flags working.
Recommended approach:
- Add
command: 'run' | 'operator'toCliArgs. - Detect
operatoras the first positional arg inconstructArgs. - Continue using the existing
--passthrough split logic soconstruct operator -- --continueworks.
Create a small module, e.g. src/operator.ts, to keep index.ts clean.
Proposed API:
export interface OperatorOptions {
passthroughArgs: string[];
}
export async function runOperator(options: OperatorOptions): Promise<number>;Responsibilities inside runOperator:
- Gather plugins
- Call
scanAllPlugins()once. - Build a stable list of entries for fzf:
- For each plugin, compute a single-line description.
- Feed
fzflines as TSV:<pluginId>\t<description>.
Where to get the description (make this reliable so preview is useful):
- Extend
PluginInfoto includedescription?: string. - For marketplace plugins: read
description(or equivalent) frommarketplace.jsonplugin entries when scanning (scanMarketplacePlugins). - For installed plugins: attempt to read
${installPath}/.claude-plugin/plugin.jsonand use itsdescriptionfield if present; otherwise leave description empty.
- Guardrails
- If no plugins are found: print a helpful message and return 0.
- Select plugins via
fzf
- Spawn
fzfwithstdio: ['pipe','inherit','inherit'](or Bun equivalent). - Provide TSV input as above.
- Capture stdout.
- Parse selected plugin IDs by taking field 1 (before the first tab) from each selected line.
- Resolve selection to
PluginInfo[]
- For each selected ID, look up
registry.plugins.get(id). - If missing (shouldn’t happen), warn and skip.
- Persist config (always)
- If selection non-empty:
saveConfig({ enabledPlugins: selectedIds, lastUsed: new Date().toISOString() })
- Execute
const translation = await translatePlugins(enabledPlugins)return executeCopilot({ env: translation.env, additionalMcpConfig: translation.additionalMcpConfig, passthroughArgs: options.passthroughArgs, translatedAgents: translation.translatedAgents })
In main():
- If
args.command === 'operator':- call
runOperator(...) process.exit(exitCode)
- call
- Otherwise run the existing flow unchanged.
- Add usage + examples to
README.md(minimal: one short section showingconstruct operator). - Ensure
construct --helpshows the new subcommand and its flags.
- Cancel behavior: if the user cancels fzf selection, do not save config and exit 0.
fzfis a hard dependency: if it’s not available, print a clear error and exit 1.- Missing descriptions are acceptable: preview can show an empty string or “(no description)”.
construct operatorlaunches an interactive selector and then runs copilot with selected plugins.construct operator -- --continuepasses args through to copilot.- Selecting none or cancelling exits cleanly without saving.
- Selection is written to
.construct.jsonin the current directory. - The
fzfpreview shows the selected plugin’s description.
# typecheck baseline
bun run typecheck
# run selector
bun run index.ts operator
# pass through args to copilot
bun run index.ts operator -- --help
# confirm config behavior
cat .construct.json