diff --git a/README.md b/README.md
index 6a39fec..af17570 100644
--- a/README.md
+++ b/README.md
@@ -35,6 +35,7 @@ Commands:
dev [options] Enables developer mode.
validate [options] [path] Validates the Stream Deck plugin.
pack|bundle [options] [path] Creates a .streamDeckPlugin file from the plugin.
+ add-action [options] Adds a new action to an existing Stream Deck plugin.
config Manage the local configuration.
help [command] display help for command
@@ -51,6 +52,37 @@ The `streamdeck create` command enables you to scaffold a new Stream Deck plugin
+
+## Adding actions to a plugin
+
+The `streamdeck add-action` command allows you to quickly add new actions to an existing Stream Deck plugin. The command can be used both interactively (with prompts) or with command-line parameters for automation.
+
+### Interactive mode
+
+Running the command without parameters will start an interactive prompt:
+
+```bash
+streamdeck add-action
+```
+
+This will ask you for:
+- Action name (display name)
+- Action identifier (unique ID within the plugin)
+- Action description
+- Whether to create a property inspector UI
+
+### Command-line mode
+
+You can also provide all parameters directly via command-line options:
+
+```bash
+streamdeck add-action --name "My Action" --action-id "my-action" --description "Does something awesome" (--ui|--no-ui) [--yes]
+```
+
+Notes:
+- The `--yes` option can be used to skip confirmation prompts, allowing for quick creation of actions without manual intervention.
+- The `--ui` option specifies whether to create a property inspector UI for the action. The `--no-ui` option can be used to skip creating a UI file.
+
## Further Reading
- Learn more about [Stream Deck CLI commands](https://docs.elgato.com/streamdeck/cli).
diff --git a/src/cli.ts b/src/cli.ts
index 1dce67f..fbd5d32 100644
--- a/src/cli.ts
+++ b/src/cli.ts
@@ -1,6 +1,6 @@
import { program } from "commander";
-import { config, create, link, pack, restart, setDeveloperMode, stop, validate } from "./commands";
+import { addAction, config, create, link, pack, restart, setDeveloperMode, stop, validate } from "./commands";
import { packageManager } from "./package-manager";
program.version(packageManager.getVersion({ checkEnvironment: true }), "-v", "display CLI version");
@@ -57,6 +57,17 @@ program
.option("--no-update-check", "Disables updating schemas", true)
.action((path, opts) => pack({ ...opts, path }));
+program
+ .command("add-action")
+ .description("Adds a new action to an existing Stream Deck plugin.")
+ .option("-n, --name ", "Name of the action")
+ .option("-i, --action-id ", "Action identifier (appended to plugin UUID)")
+ .option("-d, --description ", "Description of the action")
+ .option("--ui", "Create property inspector UI", true)
+ .option("--no-ui", "Skip creating property inspector UI")
+ .option("-y, --yes", "Skip confirmation prompt", false)
+ .action((opts) => addAction(opts));
+
const configCommand = program.command("config").description("Manage the local configuration.");
configCommand
diff --git a/src/commands/add-action.ts b/src/commands/add-action.ts
new file mode 100644
index 0000000..8d10d9f
--- /dev/null
+++ b/src/commands/add-action.ts
@@ -0,0 +1,450 @@
+import { Manifest } from "@elgato/schemas/streamdeck/plugins";
+import chalk from "chalk";
+import inquirer from "inquirer";
+import fs from "node:fs";
+import { join } from "node:path";
+
+import { command } from "../common/command";
+import { createCopier } from "../common/file-copier";
+import { StdOut } from "../common/stdout";
+import { readJsonFile } from "../system/fs";
+import { relative } from "../system/path";
+
+/**
+ * Options for the add-action command.
+ */
+export type AddActionOptions = {
+ /**
+ * Name of the action.
+ */
+ name?: string;
+
+ /**
+ * Action identifier (will be appended to plugin UUID).
+ */
+ actionId?: string;
+
+ /**
+ * Description of the action.
+ */
+ description?: string;
+
+ /**
+ * Whether to create a property inspector UI.
+ */
+ ui?: boolean;
+
+ /**
+ * Skip confirmation prompt.
+ */
+ yes?: boolean;
+};
+
+/**
+ * Adds a new action to an existing Stream Deck plugin.
+ */
+export const addAction = command(
+ async (options, stdout) => {
+ // Validate we're in a plugin directory
+ const pluginPath = await validatePluginDirectory(stdout);
+ if (!pluginPath) {
+ return stdout.error("Not in a valid Stream Deck plugin directory").exit(1);
+ }
+
+ // Read the existing manifest
+ const manifestPath = join(pluginPath.sdPluginPath, "manifest.json");
+ const manifest = await readJsonFile(manifestPath);
+
+ // Get plugin UUID from manifest
+ const pluginUuid = manifest.UUID;
+ if (!pluginUuid) {
+ return stdout.error("Invalid manifest: missing UUID").exit(1);
+ }
+
+ // Get action information (either from options or prompts)
+ const actionInfo = await getActionInfo(options, pluginUuid, manifest.Actions || [], stdout);
+
+ // Confirm action creation (unless --yes flag is used)
+ if (!options.yes && !(await confirmActionCreation(actionInfo, stdout))) {
+ return stdout.info("Aborted").exit();
+ }
+
+ stdout.log();
+ stdout.log(`Creating ${chalk.blue(actionInfo.name)} action...`);
+
+ // Create action files
+ await stdout.spin("Creating action class", () => createActionClass(pluginPath, actionInfo));
+ await stdout.spin("Creating UI file", () => createUIFile(pluginPath, actionInfo));
+ await stdout.spin("Creating action images", () => createActionImages(pluginPath, actionInfo));
+ await stdout.spin("Updating manifest", () => updateManifest(manifestPath, actionInfo));
+ await stdout.spin("Updating plugin registration", () => updatePluginRegistration(pluginPath, actionInfo));
+
+ stdout.log().log(chalk.green("Successfully created action!"));
+ },
+ {
+ name: undefined as any,
+ actionId: undefined as any,
+ description: undefined as any,
+ } as Required>,
+);
+
+/**
+ * Information about the action being created.
+ */
+interface ActionInfo {
+ name: string;
+ uuid: string;
+ description: string;
+ className: string;
+ fileName: string;
+ hasUI: boolean;
+}
+
+/**
+ * Information about the plugin directory structure.
+ */
+interface PluginPaths {
+ pluginPath: string;
+ sdPluginPath: string;
+ srcPath: string;
+}
+
+/**
+ * Validates that we're in a valid Stream Deck plugin directory.
+ */
+async function validatePluginDirectory(stdout: StdOut): Promise {
+ const cwd = process.cwd();
+
+ // Look for .sdPlugin directory
+ const entries = fs.readdirSync(cwd, { withFileTypes: true });
+ const sdPluginDir = entries.find((entry) => entry.isDirectory() && entry.name.endsWith(".sdPlugin"));
+
+ if (!sdPluginDir) {
+ return null;
+ }
+
+ const sdPluginPath = join(cwd, sdPluginDir.name);
+ const manifestPath = join(sdPluginPath, "manifest.json");
+ const srcPath = join(cwd, "src");
+
+ // Validate required files exist
+ if (!fs.existsSync(manifestPath)) {
+ stdout.error("manifest.json not found in .sdPlugin directory");
+ return null;
+ }
+
+ if (!fs.existsSync(srcPath)) {
+ stdout.error("src directory not found");
+ return null;
+ }
+
+ return {
+ pluginPath: cwd,
+ sdPluginPath,
+ srcPath,
+ };
+}
+
+/**
+ * Prompts the user to confirm action creation.
+ */
+async function confirmActionCreation(actionInfo: ActionInfo, stdout: StdOut): Promise {
+ stdout.log().log(chalk.cyan("Action Summary:"));
+ stdout.log(` Name: ${actionInfo.name}`);
+ stdout.log(` UUID: ${actionInfo.uuid}`);
+ stdout.log(` Description: ${actionInfo.description}`);
+ stdout.log(` Class: ${actionInfo.className}`);
+ stdout.log(` Has UI: ${actionInfo.hasUI ? "Yes" : "No"}`);
+
+ const { confirm } = await inquirer.prompt([
+ {
+ type: "confirm",
+ name: "confirm",
+ message: "Create this action?",
+ default: true,
+ },
+ ]);
+
+ return confirm;
+}
+
+/**
+ * Creates the action class file.
+ */
+async function createActionClass(pluginPaths: PluginPaths, actionInfo: ActionInfo): Promise {
+ const actionsDir = join(pluginPaths.srcPath, "actions");
+ if (!fs.existsSync(actionsDir)) {
+ fs.mkdirSync(actionsDir, { recursive: true });
+ }
+
+ const actionFilePath = join(actionsDir, `${actionInfo.fileName}.ts`);
+
+ const template = createCopier({
+ source: relative("../template/src/actions"),
+ dest: actionsDir,
+ data: {
+ uuid: actionInfo.uuid,
+ className: actionInfo.className,
+ name: actionInfo.name,
+ description: actionInfo.description,
+ },
+ });
+
+ // Copy and render the action template
+ await template.copy("generic-action.ts.ejs", `${actionInfo.fileName}.ts`);
+}
+
+/**
+ * Creates the UI file for the action.
+ */
+async function createUIFile(pluginPaths: PluginPaths, actionInfo: ActionInfo): Promise {
+ if (!actionInfo.hasUI) return;
+
+ const uiDir = join(pluginPaths.sdPluginPath, "ui");
+ if (!fs.existsSync(uiDir)) {
+ fs.mkdirSync(uiDir, { recursive: true });
+ }
+
+ const uiFilePath = join(uiDir, `${actionInfo.fileName}.html`);
+
+ const template = createCopier({
+ source: relative("../template/com.elgato.template.sdPlugin/ui"),
+ dest: uiDir,
+ data: {
+ name: actionInfo.name,
+ fileName: actionInfo.fileName,
+ },
+ });
+
+ // Copy and render the UI template
+ await template.copy("generic-action.html.ejs", `${actionInfo.fileName}.html`);
+}
+
+/**
+ * Creates action image placeholders.
+ */
+async function createActionImages(pluginPaths: PluginPaths, actionInfo: ActionInfo): Promise {
+ const imagesDir = join(pluginPaths.sdPluginPath, "imgs", "actions", actionInfo.fileName);
+ if (!fs.existsSync(imagesDir)) {
+ fs.mkdirSync(imagesDir, { recursive: true });
+ }
+
+ const template = createCopier({
+ source: relative("../template/com.elgato.template.sdPlugin/imgs/actions/counter"),
+ dest: imagesDir,
+ data: {},
+ });
+
+ // Copy placeholder images
+ await template.copy("icon.png");
+ await template.copy("icon@2x.png");
+ await template.copy("key.png");
+ await template.copy("key@2x.png");
+}
+
+/**
+ * Updates the manifest with the new action.
+ */
+async function updateManifest(manifestPath: string, actionInfo: ActionInfo): Promise {
+ const manifest = await readJsonFile(manifestPath);
+
+ const newAction = {
+ Name: actionInfo.name,
+ UUID: actionInfo.uuid,
+ Icon: `imgs/actions/${actionInfo.fileName}/icon`,
+ Tooltip: actionInfo.description,
+ Controllers: ["Keypad"],
+ States: [
+ {
+ Image: `imgs/actions/${actionInfo.fileName}/key`,
+ },
+ ],
+ } as any;
+
+ if (actionInfo.hasUI) {
+ newAction.PropertyInspectorPath = `ui/${actionInfo.fileName}.html`;
+ }
+
+ manifest.Actions = manifest.Actions || [];
+ manifest.Actions.push(newAction);
+
+ fs.writeFileSync(manifestPath, JSON.stringify(manifest, null, "\t"));
+}
+
+/**
+ * Updates the plugin registration to include the new action.
+ */
+async function updatePluginRegistration(pluginPaths: PluginPaths, actionInfo: ActionInfo): Promise {
+ const pluginFilePath = join(pluginPaths.srcPath, "plugin.ts");
+
+ if (!fs.existsSync(pluginFilePath)) {
+ return; // Skip if plugin.ts doesn't exist
+ }
+
+ let content = fs.readFileSync(pluginFilePath, "utf-8");
+
+ // Add import
+ const importStatement = `import { ${actionInfo.className} } from "./actions/${actionInfo.fileName}";`;
+ const importRegex = /import.*from.*["']@elgato\/streamdeck["'];/;
+ const match = content.match(importRegex);
+
+ if (match) {
+ content = content.replace(match[0], `${match[0]}\n\n${importStatement}`);
+ } else {
+ // Fallback: add import at the top
+ content = `${importStatement}\n${content}`;
+ }
+
+ // Add registration
+ const registrationStatement = `streamDeck.actions.registerAction(new ${actionInfo.className}());`;
+ const connectRegex = /streamDeck\.connect\(\);/;
+ const connectMatch = content.match(connectRegex);
+
+ if (connectMatch) {
+ content = content.replace(
+ connectMatch[0],
+ `// Register the ${actionInfo.name.toLowerCase()} action.\n${registrationStatement}\n\n${connectMatch[0]}`,
+ );
+ } else {
+ // Fallback: add at the end
+ content = `${content}\n\n// Register the ${actionInfo.name.toLowerCase()} action.\n${registrationStatement}`;
+ }
+
+ fs.writeFileSync(pluginFilePath, content);
+}
+
+/**
+ * Converts a string to PascalCase.
+ */
+function toPascalCase(str: string): string {
+ return str
+ .split(/[-_\s]+/)
+ .map((word) => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase())
+ .join("");
+}
+
+/**
+ * Converts a string to kebab-case.
+ */
+function toKebabCase(str: string): string {
+ return str
+ .replace(/([a-z])([A-Z])/g, "$1-$2")
+ .replace(/[\s_]+/g, "-")
+ .toLowerCase();
+}
+
+/**
+ * Gets action information from options or prompts.
+ */
+async function getActionInfo(
+ options: AddActionOptions,
+ pluginUuid: string,
+ existingActions: any[],
+ stdout: StdOut,
+): Promise {
+ const existingUuids = existingActions.map((action) => action.UUID);
+
+ // Check if boolean options were explicitly provided via CLI
+ // We need to check the original process.argv to see if flags were actually used
+ const argv = process.argv.join(" ");
+ const uiExplicitlySet = argv.includes("--ui") || argv.includes("--no-ui");
+ const yesExplicitlySet = argv.includes("--yes") || argv.includes("--no-yes");
+
+ // If all required options are provided, use them
+ if (options.name && options.actionId) {
+ // Validate the provided actionId
+ if (!/^[a-z0-9-]+$/.test(options.actionId)) {
+ return stdout.error("Action identifier must contain only lowercase letters, numbers, and hyphens").exit(1);
+ }
+
+ const fullUuid = `${pluginUuid}.${options.actionId}`;
+ if (existingUuids.indexOf(fullUuid) !== -1) {
+ return stdout.error("An action with this identifier already exists").exit(1);
+ }
+
+ const className = toPascalCase(options.actionId);
+ const fileName = toKebabCase(options.actionId);
+
+ return {
+ name: options.name,
+ uuid: fullUuid,
+ description: options.description || `${options.name} action`,
+ className,
+ fileName,
+ hasUI: uiExplicitlySet ? options.ui! : true, // Default to true if not explicitly set
+ };
+ }
+
+ // Otherwise, prompt for missing information
+ const prompts: any[] = [];
+
+ if (!options.name) {
+ prompts.push({
+ type: "input",
+ name: "name",
+ message: "Action name:",
+ validate: (input: string) => {
+ if (!input.trim()) return "Action name is required";
+ return true;
+ },
+ });
+ }
+
+ if (!options.actionId) {
+ prompts.push({
+ type: "input",
+ name: "actionId",
+ message: "Action identifier (will be appended to plugin UUID):",
+ validate: (input: string) => {
+ if (!input.trim()) return "Action identifier is required";
+ if (!/^[a-z0-9-]+$/.test(input))
+ return "Action identifier must contain only lowercase letters, numbers, and hyphens";
+ const fullUuid = `${pluginUuid}.${input}`;
+ if (existingUuids.indexOf(fullUuid) !== -1) return "An action with this identifier already exists";
+ return true;
+ },
+ });
+ }
+
+ if (!options.description) {
+ prompts.push({
+ type: "input",
+ name: "description",
+ message: "Action description:",
+ default: (answers: any) => `${options.name || answers.name} action`,
+ });
+ }
+
+ // Only prompt for UI if not explicitly set via CLI
+ if (!uiExplicitlySet) {
+ prompts.push({
+ type: "confirm",
+ name: "hasUI",
+ message: "Create property inspector UI?",
+ default: true,
+ });
+ }
+
+ // Handle case where no prompts are needed
+ let answers: any = {};
+ if (prompts.length > 0) {
+ answers = await inquirer.prompt(prompts);
+ }
+
+ const name = options.name || answers.name;
+ const actionId = options.actionId || answers.actionId;
+ const description = options.description || answers.description;
+ const hasUI = uiExplicitlySet ? options.ui! : answers.hasUI !== undefined ? answers.hasUI : true;
+
+ const className = toPascalCase(actionId);
+ const fileName = toKebabCase(actionId);
+
+ return {
+ name,
+ uuid: `${pluginUuid}.${actionId}`,
+ description,
+ className,
+ fileName,
+ hasUI,
+ };
+}
diff --git a/src/commands/index.ts b/src/commands/index.ts
index 5a8248b..3dcea2c 100644
--- a/src/commands/index.ts
+++ b/src/commands/index.ts
@@ -1,3 +1,4 @@
+export { addAction } from "./add-action";
export * as config from "./config";
export { create } from "./create";
export { setDeveloperMode } from "./dev";
diff --git a/template/com.elgato.template.sdPlugin/ui/generic-action.html.ejs b/template/com.elgato.template.sdPlugin/ui/generic-action.html.ejs
new file mode 100644
index 0000000..f694ce7
--- /dev/null
+++ b/template/com.elgato.template.sdPlugin/ui/generic-action.html.ejs
@@ -0,0 +1,47 @@
+
+
+
+
+ <%= name %> Settings
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/template/src/actions/generic-action.ts.ejs b/template/src/actions/generic-action.ts.ejs
new file mode 100644
index 0000000..2739f14
--- /dev/null
+++ b/template/src/actions/generic-action.ts.ejs
@@ -0,0 +1,40 @@
+import { action, KeyDownEvent, SingletonAction, WillAppearEvent } from "@elgato/streamdeck";
+
+/**
+ * <%= description %>
+ */
+@action({ UUID: "<%= uuid %>" })
+export class <%= className %> extends SingletonAction {
+ /**
+ * The {@link SingletonAction.onWillAppear} event is useful for setting the visual representation of an action when it becomes visible. This could be due to the Stream Deck first
+ * starting up, or the user navigating between pages / folders etc.. There is also an inverse of this event in the form of {@link streamDeck.client.onWillDisappear}. In this example,
+ * we're setting up the initial state of the action.
+ */
+ override onWillAppear(ev: WillAppearEvent): void | Promise {
+ // TODO: Implement action appearance logic
+ return ev.action.setTitle("<%= name %>");
+ }
+
+ /**
+ * Listens for the {@link SingletonAction.onKeyDown} event which is emitted by Stream Deck when an action is pressed. Stream Deck provides various events for tracking interaction
+ * with devices including key down/up, dial rotations, and device connectivity, etc. When triggered, {@link ev} object contains information about the event including any payloads
+ * and action information where applicable.
+ */
+ override async onKeyDown(ev: KeyDownEvent): Promise {
+ // TODO: Implement action key down logic
+ const { settings } = ev.payload;
+
+ // Example: Update action title or perform some action
+ await ev.action.setTitle("Pressed!");
+ }
+}
+
+/**
+ * Settings for {@link <%= className %>}.
+ */
+type Settings = {
+ // TODO: Define your action settings here
+ // Example:
+ // someProperty?: string;
+ // count?: number;
+};