Plugin Cli Commands
DL#142: Plugin CLI Commands
Written for AI agents. See Log Methodology Note below for details.
Background
Plugins need to run administrative operations that don't belong in the rendering pipeline or the dev server request cycle. Example: a media plugin that uploads files from the project's public/ folder to Wix Media, or a stores plugin that syncs product data from an external CMS.
Today plugins can declare:
- Services — initialized at startup, available to components and actions
- Actions — request-scoped server functions (
makeJayAction/makeJayQuery) for client-server communication - Setup handlers — run via
jay-stack setup, create config files, validate credentials (DL#87) - References — run via
jay-stack agent-kit, generate discovery data for AI agents (DL#87)
None of these fit "upload media files" or "sync products":
- Actions are request-response — they need a running server and an HTTP caller
- Setup handlers are for config creation and validation, not arbitrary operations
- References are for generating data files, not side effects
Problem
There's no mechanism for a plugin to expose a CLI command that:
- Runs as a one-shot terminal operation (not a server endpoint)
- Has access to initialized services (e.g., Wix Media API client)
- Can accept arguments (e.g., folder path, flags)
- Can show progress and output in the terminal
- Is discoverable — the user knows what commands are available
Use cases
- Media upload:
jay-stack run media/upload-public— uploads all files frompublic/to Wix Media, returns a URL mapping - Product sync:
jay-stack run stores/sync-catalog— pulls product data from external CMS into local reference files - Schema migration:
jay-stack run data/migrate --dry-run— runs database migrations declared by a plugin - Cache clear:
jay-stack run cdn/purge --all— purges CDN cache for the site - Cloud deployment:
jay-stack run wix/deploy --env production— deploys the built application to a cloud provider (uploads frontend to CDN, pushes backend container, runs health checks)
Questions and Answers
Q1: Should this be a new CLI command or extend an existing one?
A1: New subcommand jay-stack run <plugin>/<command>. This parallels the existing jay-stack action <plugin>/<action> pattern but makes the distinction clear: actions are HTTP endpoints, commands are CLI operations. The run verb communicates "execute this thing once."
Q2: Should commands be declared in plugin.yaml or auto-discovered?
A2: Declared in plugin.yaml. This is consistent with how actions, services, webhooks, and setup handlers are declared. Explicit declaration provides discoverability (jay-stack run --list) and documentation (description field).
Q3: Should command handlers get the same context as setup handlers?
A3: Similar but not identical. Commands need services (like setup), but they also need typed input parsed from CLI arguments, and they don't need configDir. The handler uses a makeCliCommand builder with .withServices(), just like makeJayAction.
Q4: How do commands declare their arguments?
A4: In a .jay-command YAML file (same pattern as .jay-action). The file declares inputSchema with typed parameters. The CLI reads the schema and auto-generates commander flags — --folder <string>, --dry-run (boolean), etc. This means the CLI natively validates and parses arguments before calling the handler. No raw passthrough needed.
Q5: Should commands run in a Vite context (for TypeScript loading)?
A5: Yes. Same as jay-stack setup and jay-stack action — the handler module is loaded via viteServer.ssrLoadModule() so plugins can be authored in TypeScript. Services are initialized the same way.
Q6: How does this relate to dev-time actions?
A6: They're complementary. A dev-time action would be an HTTP endpoint available only during jay-stack dev — useful for UI-driven admin tools (editor panels, dashboards). A CLI command is for terminal-driven batch operations. Both have access to services. This design log covers CLI commands only. Dev-time actions can be added later if needed, likely as a scope: dev flag on makeJayAction.
Q7: Should commands have access to the project's public folder path?
A7: Yes. The publicFolder path (from project config or default ./public) should be in the context. The media upload use case needs this directly.
Q8: Can a command return structured data (like actions do)?
A8: Yes. The handler returns a result object. The CLI renders it as JSON or YAML (with --yaml flag), same as jay-stack action. For progress output during execution, the handler uses the logger directly.
Design
1. Console context service
A framework-provided service that gives CLI commands access to project info and a logger. Commands request it via .withServices() like any other service:
import { createJayService } from '@jay-framework/fullstack-component';
export interface ConsoleContext {
projectRoot: string;
publicFolder: string;
build: {
frontend: string;
backend: string;
};
verbose: boolean;
log: (message: string) => void;
warn: (message: string) => void;
error: (message: string) => void;
}
export const CONSOLE_CONTEXT = createJayService<ConsoleContext>('ConsoleContext');
The CLI registers this service before executing the command. Commands that don't need project info simply don't request it.
2. makeCliCommand builder
Follows the same builder pattern as makeJayAction — declares services, accepts typed input, returns success/failure:
import { makeCliCommand } from '@jay-framework/fullstack-component';
import { MEDIA_SERVICE } from './services';
import { CONSOLE_CONTEXT } from '@jay-framework/fullstack-component';
export const uploadPublic = makeCliCommand('upload-public')
.withServices(MEDIA_SERVICE, CONSOLE_CONTEXT)
.withHandler(async (input, mediaService, console) => {
const fs = await import('node:fs/promises');
const path = await import('node:path');
const folder = input.folder || '';
const publicPath = path.resolve(console.publicFolder, folder);
const files = await fs.readdir(publicPath, { recursive: true });
let count = 0;
for (const file of files) {
const filePath = path.join(publicPath, String(file));
const stat = await fs.stat(filePath);
if (!stat.isFile()) continue;
if (input.dryRun) {
console.log(`[dry-run] Would upload ${file}`);
continue;
}
const url = await mediaService.upload(filePath);
console.log(`Uploaded ${file} → ${url}`);
count++;
}
console.log(`Done. ${count} files uploaded.`);
return { success: true };
});
The builder produces a JayCliCommand object:
interface JayCliCommand<Input> {
commandName: string;
services: ServiceMarkers<any[]>;
handler: (input: Input, ...services: any[]) => Promise<{ success: boolean }>;
_brand: 'JayCliCommand';
}
The handler returns { success: true } or { success: false }. The CLI maps this to exit code 0 or 1. All console output goes through the ConsoleContext logger (or any other service the command requests).
2. .jay-command metadata file
Like .jay-action, a YAML file declares the command's description and input schema:
# upload-public.jay-command
name: upload-public
description: Upload files from the public folder to Wix Media
inputSchema:
folder?: string # Subfolder within public/ (default: entire public/)
dryRun?: boolean # Preview without uploading
No outputSchema — CLI commands write output directly to the console via a logger. The handler returns a success/failure status to determine exit code.
The CLI reads inputSchema and auto-generates commander flags:
folder?: string→--folder <value>(optional string flag)dryRun?: boolean→--dry-run(boolean flag, camelCase → kebab-case)
Required fields (no ?) become required flags — the CLI validates them before calling the handler.
3. Plugin manifest declaration
# plugin.yaml
name: wix-media
commands:
- name: upload-public
command: upload-public.jay-command # path to .jay-command file
- name: clear-cache
command: clear-cache.jay-command
The command field points to the .jay-command file (same as action pointing to .jay-action). The handler export is discovered by name from the plugin module (matching the command name, like actions).
4. CLI invocation
# Run a command — flags auto-generated from .jay-command inputSchema
jay-stack run media/upload-public --folder images --dry-run
# List available commands (from all plugins)
jay-stack run --list
# YAML output
jay-stack run stores/sync-catalog --yaml
# Verbose
jay-stack run media/upload-public -v
The CLI natively parses --folder images and --dry-run because it read the inputSchema. The handler receives { folder: 'images', dryRun: true } — typed, validated, no manual parsing needed.
5. Discovery and execution flow
jay-stack run media/upload-public --folder images --dry-run
│
├─ scanPlugins() → find plugins with `commands` in plugin.yaml
├─ Match "media/upload-public" → plugin "wix-media", command "upload-public"
├─ Read upload-public.jay-command → get inputSchema, description
├─ Auto-parse CLI flags from inputSchema → { folder: 'images', dryRun: true }
├─ createViteForCli() → TypeScript loading
├─ initializeServices() → register all plugin services
├─ Register CONSOLE_CONTEXT service (projectRoot, publicFolder, logger)
├─ viteServer.ssrLoadModule(pluginModule) → load handler export
│
▼
handler({ folder: 'images', dryRun: true }, mediaService, consoleContext)
│
├─ Handler uses injected services and console context
├─ Writes output via consoleContext.log()
│
▼
Return { success: true }
│
└─ Exit 0 (success) or 1 (failure)
6. --list output
Available plugin commands:
wix-media
upload-public Upload files from the public folder to Wix Media
clear-cache Clear media CDN cache
wix-stores
sync-catalog Sync product catalog from external CMS
wix-deploy
deploy Deploy application to Wix cloud
7. Input type mapping (.jay-command → CLI flags)
| Schema type | CLI flag | Example |
|---|---|---|
field: string |
--field <value> (required) |
--env production |
field?: string |
--field <value> (optional) |
--folder images |
field: boolean |
--field (required, must be present) |
rare |
field?: boolean |
--field (optional flag) |
--dry-run |
field: number |
--field <value> (required, parsed as number) |
--concurrency 4 |
field?: number |
--field <value> (optional number) |
--timeout 30 |
camelCase field names become kebab-case flags: dryRun → --dry-run.
8. CONSOLE_CONTEXT service
Registered by the CLI before executing the command. Available to any command that requests it via .withServices(CONSOLE_CONTEXT). Commands that don't need project info simply don't request it — they only declare the services they need.
| Field | Type | Description |
|---|---|---|
projectRoot |
string |
Absolute path to project root |
publicFolder |
string |
Absolute path to public folder |
build.frontend |
string |
Absolute path to frontend build output (JS, CSS, public assets) |
build.backend |
string |
Absolute path to backend build output (server modules, pre-rendered HTML) |
verbose |
boolean |
Whether -v / --verbose was passed |
log(msg) |
function |
Write info to console |
warn(msg) |
function |
Write warning to console |
error(msg) |
function |
Write error to console |
Implementation Plan
Phase 1: Builder and types
full-stack-component/lib/jay-command-builder.ts (new):
makeCliCommand(name)builder with.withServices()and.withHandler()JayCliCommandinterface (commandName, services, handler returns{ success: boolean },_brand)isJayCliCommand()type guardCONSOLE_CONTEXTservice marker andConsoleContextinterface- Export from package index
Phase 2: Discovery and execution
stack-server-runtime/lib/plugin-commands.ts (new):
discoverPluginCommands({ projectRoot, pluginFilter? })— scans plugin.yaml forcommands, resolves.jay-commandfilesexecutePluginCommand(plugin, command, input, viteServer)— loads handler via Vite, resolves services, calls handler- Parse
.jay-commandYAML — extractinputSchema,description,outputSchema commandSchemaToFlags(inputSchema)— convert schema to commander option definitions
stack-server-runtime/lib/plugin-scanner.ts: 5. Add commands to PluginManifest type (optional array)
Phase 3: CLI command
stack-cli/lib/run-command.ts (new):
runCommand(commandRef, args, options, projectRoot, initializeServices)handler- Parse
commandRefaspluginName/commandName - Discover commands, read
.jay-command, auto-generate flags from inputSchema - Parse CLI args against schema, validate required fields
- Init Vite + services, register
CONSOLE_CONTEXTwith project info and logger - Execute handler, exit code from
{ success }result - Handle
--listflag
stack-cli/lib/cli.ts: 8. Register run command with allowUnknownOption() for schema-derived flags
Phase 4: Documentation
Agent-kit templates:
- Update
plugin/plugin-structure.md— addcommandsfield to plugin.yaml docs - Add
plugin/commands-guide.md— how to writemakeCliCommandhandlers and.jay-commandfiles
stack-cli/agent-kit-template/devops/: 3. Add reference to jay-stack run in devops guide
Examples
Media plugin — upload public files
# plugin.yaml
name: wix-media
commands:
- name: upload-public
handler: uploadPublicCommand
description: Upload public folder files to Wix Media
$ jay-stack run media/upload-public
Uploaded images/logo.png → https://static.wixstatic.com/media/abc123
Uploaded images/hero.jpg → https://static.wixstatic.com/media/def456
{
"success": true,
"message": "Uploaded 2 files",
"data": {
"uploaded": [
{ "local": "images/logo.png", "url": "https://static.wixstatic.com/media/abc123" },
{ "local": "images/hero.jpg", "url": "https://static.wixstatic.com/media/def456" }
]
}
}
Stores plugin — sync catalog
# plugin.yaml
name: wix-stores
commands:
- name: sync-catalog
handler: syncCatalogCommand
description: Sync product catalog to local reference files
$ jay-stack run stores/sync-catalog --yaml
success: true
message: Synced 47 products
data:
products: 47
categories: 5
outputDir: agent-kit/references/wix-stores/
Trade-offs
| Aspect | Benefit | Cost |
|---|---|---|
New CLI command (run) |
Clear separation from actions and setup | One more command to learn |
makeCliCommand builder |
Consistent with makeJayAction, type-safe services |
New builder to implement |
.jay-command YAML (no outputSchema) |
CLI auto-generates flags, validates input, self-documenting | Another file format (but mirrors .jay-action) |
| Native flag parsing from schema | No manual arg parsing in handlers, consistent UX | Schema must cover all parameters upfront |
CONSOLE_CONTEXT service |
Opt-in — commands request only what they need, no magic fields in input | One more framework service to know about |
Handler returns { success } only |
Simple contract, output is console logs not structured data | No machine-readable output (use actions for that) |
| Service injection via builder | Same pattern as actions — plugins reuse service infrastructure | Requires full service initialization even for simple commands |
| Vite for TypeScript | Plugins authored in TypeScript seamlessly | Adds ~1s startup overhead |
Verification Criteria
jay-stack run --listshows all plugin commands with descriptionsjay-stack run media/upload-publicexecutes the handler with initialized servicesjay-stack run media/upload-public --yamloutputs YAML instead of JSON- Handler receives
publicFolderpath in context - Handler can access services via
ctx.services - Unknown plugin/command prints helpful error with available commands
- Exit code 0 on success, 1 on failure
--verboseflag passed through to handler context
Log Methodology Note
Note: These design logs are written primarily for AI agents as part of the Design Log methodology and made accessible here for human readers. The language and structure are optimized for machine consumption — expect precise, specification-style prose rather than narrative documentation.