openpencil/apps/cli/src/index.ts
Kayshen-X a7d73ebb62 feat(ai): pencil-style agentic design tool-loop, multi-chat tabs, #27 panel restyle
Built-in design generation now runs as an agentic MCP tool-loop (reusing the
agent-rs BuiltInProvider), gated behind OPENPENCIL_DESIGN_AGENT_LOOP / the
Settings experimental toggle; the orchestrator stays the default.

- design-agent system prompt + in-process design toolset (parity-locked with
  the MCP surface) + flag-gated Intent::Design routing
- spawn_agents execution as sequential sub-loops + live creation-mode badges
  (per-agent glow + 'N/M designing...' header)
- new MCP tools: get_guidelines, ToolSearch, get_screenshot, get_editor_state,
  export_nodes, spawn_agents; style-guide local audit
- #27 AI panel restyle: rounded tool cards + green check-rings, gray user
  bubbles, model-pill bottom toolbar, header, empty-state pills, the
  PARALLEL AGENTS (agent_team_size) 1x-6x chip dropdown
- multi-chat tabs: ChatSessions model (Deref-to-active) + tab row UI
  (switch / close / + / Cmd+T) with each run bound to its tab

Large checkpoint commit spanning the working tree (Rust shell crates).
2026-07-02 21:21:06 +08:00

456 lines
13 KiB
JavaScript

#!/usr/bin/env node
import pkg from '../package.json';
import { setPretty, output, outputError } from './output';
// --- Arg parsing ---
interface ParsedArgs {
command: string;
positionals: string[];
flags: Record<string, string | boolean>;
}
function parseArgs(argv: string[]): ParsedArgs {
const args = argv.slice(2);
const positionals: string[] = [];
const flags: Record<string, string | boolean> = {};
for (let i = 0; i < args.length; i++) {
const arg = args[i];
if (arg.startsWith('--')) {
const key = arg.slice(2);
const next = args[i + 1];
if (next && !next.startsWith('--')) {
flags[key] = next;
i++;
} else {
flags[key] = true;
}
} else {
positionals.push(arg);
}
}
return {
command: positionals[0] ?? '',
positionals: positionals.slice(1),
flags,
};
}
// --- Help ---
const HELP = `OpenPencil CLI v${pkg.version}
Usage: op <command> [options]
App:
op start [--desktop|--web] Launch OpenPencil
op stop Stop running instance
op status Check if running
Document:
op open [file.op] Open file or connect to live canvas
op save <file.op> Save current document to file
op get [--type X] [--name Y] [--id Z] [--depth N]
op selection Get current canvas selection
Nodes:
op insert <json> [--parent P] [--index N] [--post-process]
op update <id> <json> [--post-process]
op delete <id>
op move <id> --parent <P> [--index N]
op copy <id> [--parent P]
op replace <id> <json> [--post-process]
Design:
op design <dsl|@file|->
op design:skeleton <json|@file|->
op design:content <section-id> <json|@file|->
op design:refine --root-id <id>
Read Nodes:
op read-nodes [ids] [--depth N] [--vars] [--page P] [--file F]
ids: comma-separated node IDs (omit to read all)
Codegen Pipeline:
op codegen:plan <plan-json|@file|->
op codegen:submit <planId> <chunk-result|@file|->
op codegen:assemble <planId> [--framework react]
op codegen:clean <planId>
Variables & Themes:
op vars Get variables
op vars:set <json> Set variables
op themes Get themes
op themes:set <json> Set themes
op theme:save <file.optheme> Save theme preset
op theme:load <file.optheme> Load theme preset
op theme:list [dir] List theme presets
Pages:
op page list
op page add [--name N]
op page remove <id>
op page rename <id> <name>
op page reorder <id> <index>
op page duplicate <id>
Import:
op import:svg <file.svg> Import SVG file
op import:figma <file.fig> Import Figma file
Skill:
op install [--target T] Install openpencil-skill for AI agents
op uninstall [--target T] Uninstall openpencil-skill
Targets: claude, codex, cursor, gemini, opencode (auto-detected if omitted)
Layout:
op layout [--parent P] [--depth N]
op find-space [--direction right|bottom|left|top]
Arguments that accept JSON or DSL can be passed as:
<value> Inline string
@filepath Read from file (e.g. @design.txt)
- Read from stdin (e.g. cat design.txt | op design -)
Global Flags:
--file <path> Target .op file (default: live canvas)
--page <id> Target page ID
--pretty Human-readable JSON output
--help Show this help
--version Show version
`;
// --- Main ---
async function main(): Promise<void> {
const { command, positionals, flags } = parseArgs(process.argv);
if (flags.pretty) setPretty(true);
if (flags.help || command === 'help') {
process.stdout.write(HELP);
return;
}
if (flags.version || command === 'version') {
output({ version: pkg.version });
return;
}
if (!command) {
process.stdout.write(HELP);
return;
}
const globalFlags = {
file: flags.file as string | undefined,
page: flags.page as string | undefined,
};
switch (command) {
// --- App ---
case 'start': {
const { cmdStart } = await import('./commands/app');
await cmdStart({ desktop: !!flags.desktop, web: !!flags.web });
break;
}
case 'stop': {
const { cmdStop } = await import('./commands/app');
await cmdStop();
break;
}
case 'status': {
const { cmdStatus } = await import('./commands/app');
await cmdStatus();
break;
}
// --- Document ---
case 'open': {
const { cmdOpen } = await import('./commands/document');
await cmdOpen(positionals, globalFlags);
break;
}
case 'save': {
const { cmdSave } = await import('./commands/document');
await cmdSave(positionals, globalFlags);
break;
}
case 'get': {
const { cmdGet } = await import('./commands/document');
await cmdGet(positionals, {
...globalFlags,
type: flags.type as string | undefined,
name: flags.name as string | undefined,
id: flags.id as string | undefined,
depth: flags.depth as string | undefined,
parent: flags.parent as string | undefined,
});
break;
}
case 'selection': {
const { cmdSelection } = await import('./commands/document');
await cmdSelection({ ...globalFlags, depth: flags.depth as string | undefined });
break;
}
// --- Nodes ---
case 'insert': {
const { cmdInsert } = await import('./commands/nodes');
await cmdInsert(positionals, {
...globalFlags,
parent: flags.parent as string | undefined,
index: flags.index as string | undefined,
postProcess: !!flags['post-process'],
});
break;
}
case 'update': {
const { cmdUpdate } = await import('./commands/nodes');
await cmdUpdate(positionals, {
...globalFlags,
postProcess: !!flags['post-process'],
});
break;
}
case 'delete': {
const { cmdDelete } = await import('./commands/nodes');
await cmdDelete(positionals, globalFlags);
break;
}
case 'move': {
const { cmdMove } = await import('./commands/nodes');
await cmdMove(positionals, {
...globalFlags,
parent: flags.parent as string | undefined,
index: flags.index as string | undefined,
});
break;
}
case 'copy': {
const { cmdCopy } = await import('./commands/nodes');
await cmdCopy(positionals, {
...globalFlags,
parent: flags.parent as string | undefined,
});
break;
}
case 'replace': {
const { cmdReplace } = await import('./commands/nodes');
await cmdReplace(positionals, {
...globalFlags,
postProcess: !!flags['post-process'],
});
break;
}
// --- Design ---
case 'design': {
const { cmdDesign } = await import('./commands/design');
await cmdDesign(positionals, {
...globalFlags,
postProcess: flags['post-process'] !== false ? true : undefined,
canvasWidth: flags['canvas-width'] as string | undefined,
});
break;
}
case 'design:skeleton': {
const { cmdDesignSkeleton } = await import('./commands/design');
await cmdDesignSkeleton(positionals, globalFlags);
break;
}
case 'design:content': {
const { cmdDesignContent } = await import('./commands/design');
await cmdDesignContent(positionals, {
...globalFlags,
canvasWidth: flags['canvas-width'] as string | undefined,
});
break;
}
case 'design:refine': {
const { cmdDesignRefine } = await import('./commands/design');
await cmdDesignRefine(positionals, {
...globalFlags,
rootId: flags['root-id'] as string | undefined,
canvasWidth: flags['canvas-width'] as string | undefined,
});
break;
}
// --- Read Nodes ---
case 'read-nodes': {
const { cmdReadNodes } = await import('./commands/read-nodes');
await cmdReadNodes(positionals, {
file: globalFlags.file,
page: globalFlags.page,
depth: flags.depth as string | undefined,
vars: !!flags.vars,
});
break;
}
// --- Codegen Pipeline ---
case 'codegen:plan': {
const { cmdCodegenPlan } = await import('./commands/codegen');
await cmdCodegenPlan(positionals, globalFlags);
break;
}
case 'codegen:submit': {
const { cmdCodegenSubmit } = await import('./commands/codegen');
await cmdCodegenSubmit(positionals, globalFlags);
break;
}
case 'codegen:assemble': {
const { cmdCodegenAssemble } = await import('./commands/codegen');
await cmdCodegenAssemble(positionals, {
...globalFlags,
framework: flags.framework as string | undefined,
});
break;
}
case 'codegen:clean': {
const { cmdCodegenClean } = await import('./commands/codegen');
await cmdCodegenClean(positionals, globalFlags);
break;
}
// --- Variables & Themes ---
case 'vars': {
const { cmdVars } = await import('./commands/variables');
await cmdVars(globalFlags);
break;
}
case 'vars:set': {
const { cmdVarsSet } = await import('./commands/variables');
await cmdVarsSet(positionals, { ...globalFlags, replace: !!flags.replace });
break;
}
case 'themes': {
const { cmdThemes } = await import('./commands/variables');
await cmdThemes(globalFlags);
break;
}
case 'themes:set': {
const { cmdThemesSet } = await import('./commands/variables');
await cmdThemesSet(positionals, { ...globalFlags, replace: !!flags.replace });
break;
}
case 'theme:save': {
const { cmdThemeSave } = await import('./commands/variables');
await cmdThemeSave(positionals, globalFlags);
break;
}
case 'theme:load': {
const { cmdThemeLoad } = await import('./commands/variables');
await cmdThemeLoad(positionals, globalFlags);
break;
}
case 'theme:list': {
const { cmdThemeList } = await import('./commands/variables');
await cmdThemeList(positionals);
break;
}
// --- Pages ---
case 'page': {
const subCmd = positionals[0];
const subArgs = positionals.slice(1);
switch (subCmd) {
case 'list': {
const { cmdPageList } = await import('./commands/pages');
await cmdPageList(globalFlags);
break;
}
case 'add': {
const { cmdPageAdd } = await import('./commands/pages');
await cmdPageAdd(subArgs, { ...globalFlags, name: flags.name as string | undefined });
break;
}
case 'remove': {
const { cmdPageRemove } = await import('./commands/pages');
await cmdPageRemove(subArgs, globalFlags);
break;
}
case 'rename': {
const { cmdPageRename } = await import('./commands/pages');
await cmdPageRename(subArgs, globalFlags);
break;
}
case 'reorder': {
const { cmdPageReorder } = await import('./commands/pages');
await cmdPageReorder(subArgs, globalFlags);
break;
}
case 'duplicate': {
const { cmdPageDuplicate } = await import('./commands/pages');
await cmdPageDuplicate(subArgs, globalFlags);
break;
}
default:
outputError(
`Unknown page subcommand: "${subCmd}". Use: list, add, remove, rename, reorder, duplicate`,
);
}
break;
}
// --- Import ---
case 'import:svg': {
const { cmdImportSvg } = await import('./commands/import');
await cmdImportSvg(positionals, {
...globalFlags,
parent: flags.parent as string | undefined,
});
break;
}
case 'import:figma': {
const { cmdImportFigma } = await import('./commands/import');
await cmdImportFigma(positionals, {
...globalFlags,
out: flags.out as string | undefined,
});
break;
}
// --- Skill install ---
case 'install': {
const { cmdInstall } = await import('./commands/install');
cmdInstall({ target: flags.target as string | undefined });
break;
}
case 'uninstall': {
const { cmdUninstall } = await import('./commands/install');
cmdUninstall({ target: flags.target as string | undefined });
break;
}
// --- Layout ---
case 'layout': {
const { cmdLayout } = await import('./commands/layout');
await cmdLayout({
...globalFlags,
parent: flags.parent as string | undefined,
depth: flags.depth as string | undefined,
});
break;
}
case 'find-space': {
const { cmdFindSpace } = await import('./commands/layout');
await cmdFindSpace({
...globalFlags,
direction: flags.direction as string | undefined,
width: flags.width as string | undefined,
height: flags.height as string | undefined,
});
break;
}
default:
outputError(`Unknown command: "${command}". Run "op --help" for usage.`);
}
}
main().catch((err) => {
outputError(err instanceof Error ? err.message : String(err));
});