From 45f977693b01885ac33f99fa5b059ba68f4b0bff Mon Sep 17 00:00:00 2001 From: MarioCadenas Date: Tue, 7 Apr 2026 12:35:07 +0200 Subject: [PATCH 1/6] feat(cli): add non-interactive flags and help examples for agent readiness Phase 1: Add flag-based non-interactive paths to `plugin create` and `plugin add-resource` so agents can invoke them headlessly. Includes --placement, --path, --name, --description, --resources, --resources-json, --force for create; --type, --required/--no-required, --resource-key, --permission, --fields-json, --dry-run for add-resource. Phase 2: Add Examples sections via addHelpText to all CLI commands so agents can pattern-match correct invocations from --help output. Signed-off-by: MarioCadenas --- packages/shared/src/cli/commands/docs.ts | 10 + .../shared/src/cli/commands/generate-types.ts | 9 + packages/shared/src/cli/commands/lint.ts | 6 + .../plugin/add-resource/add-resource.ts | 143 ++++++++- .../src/cli/commands/plugin/create/create.ts | 272 +++++++++++++++--- .../shared/src/cli/commands/plugin/index.ts | 12 +- .../src/cli/commands/plugin/list/list.ts | 9 + .../src/cli/commands/plugin/sync/sync.ts | 10 + .../cli/commands/plugin/validate/validate.ts | 9 + packages/shared/src/cli/commands/setup.ts | 7 + 10 files changed, 440 insertions(+), 47 deletions(-) diff --git a/packages/shared/src/cli/commands/docs.ts b/packages/shared/src/cli/commands/docs.ts index b3a7f357..6b001b72 100644 --- a/packages/shared/src/cli/commands/docs.ts +++ b/packages/shared/src/cli/commands/docs.ts @@ -144,4 +144,14 @@ export const docsCommand = new Command("docs") "Section name (e.g. 'plugins') or path to a doc file (e.g. './docs.md')", ) .option("--full", "Show complete index including all API reference entries") + .addHelpText( + "after", + ` +Examples: + $ appkit docs + $ appkit docs plugins + $ appkit docs "appkit-ui API reference" + $ appkit docs ./docs/plugins/analytics.md + $ appkit docs --full`, + ) .action(runDocs); diff --git a/packages/shared/src/cli/commands/generate-types.ts b/packages/shared/src/cli/commands/generate-types.ts index 3be45091..a11680cd 100644 --- a/packages/shared/src/cli/commands/generate-types.ts +++ b/packages/shared/src/cli/commands/generate-types.ts @@ -66,4 +66,13 @@ export const generateTypesCommand = new Command("generate-types") ) .argument("[warehouseId]", "Databricks warehouse ID") .option("--no-cache", "Disable caching for type generation") + .addHelpText( + "after", + ` +Examples: + $ appkit generate-types + $ appkit generate-types . client/src/types.d.ts + $ appkit generate-types . client/src/types.d.ts my-warehouse-id + $ appkit generate-types --no-cache`, + ) .action(runGenerateTypes); diff --git a/packages/shared/src/cli/commands/lint.ts b/packages/shared/src/cli/commands/lint.ts index ad39a961..00eb55ad 100644 --- a/packages/shared/src/cli/commands/lint.ts +++ b/packages/shared/src/cli/commands/lint.ts @@ -143,4 +143,10 @@ function runLint() { export const lintCommand = new Command("lint") .description("Run AST-based linting on TypeScript files") + .addHelpText( + "after", + ` +Examples: + $ appkit lint`, + ) .action(runLint); diff --git a/packages/shared/src/cli/commands/plugin/add-resource/add-resource.ts b/packages/shared/src/cli/commands/plugin/add-resource/add-resource.ts index e614cd17..dc1a327e 100644 --- a/packages/shared/src/cli/commands/plugin/add-resource/add-resource.ts +++ b/packages/shared/src/cli/commands/plugin/add-resource/add-resource.ts @@ -4,7 +4,12 @@ import process from "node:process"; import { cancel, intro, outro } from "@clack/prompts"; import { Command } from "commander"; import { promptOneResource } from "../create/prompt-resource"; -import { humanizeResourceType } from "../create/resource-defaults"; +import { + DEFAULT_PERMISSION_BY_TYPE, + getDefaultFieldsForType, + humanizeResourceType, + resourceKeyFromType, +} from "../create/resource-defaults"; import { resolveManifestInDir } from "../manifest-resolve"; import type { PluginManifest, ResourceRequirement } from "../manifest-types"; import { validateManifest } from "../validate/validate-manifest"; @@ -14,17 +19,29 @@ interface ManifestWithExtras extends PluginManifest { [key: string]: unknown; } -async function runPluginAddResource(options: { path?: string }): Promise { - intro("Add resource to plugin manifest"); +interface AddResourceOptions { + path?: string; + type?: string; + required?: boolean; + resourceKey?: string; + description?: string; + permission?: string; + fieldsJson?: string; + dryRun?: boolean; +} - const cwd = process.cwd(); - const pluginDir = path.resolve(cwd, options.path ?? "."); +function loadManifest( + pluginDir: string, +): { manifest: ManifestWithExtras; manifestPath: string } | null { const resolved = resolveManifestInDir(pluginDir, { allowJsManifest: true }); if (!resolved) { console.error( `No manifest found in ${pluginDir}. This command requires manifest.json (manifest.js cannot be edited in place).`, ); + console.error( + " appkit plugin add-resource --path ", + ); process.exit(1); } @@ -37,7 +54,6 @@ async function runPluginAddResource(options: { path?: string }): Promise { const manifestPath = resolved.path; - let manifest: ManifestWithExtras; try { const raw = fs.readFileSync(manifestPath, "utf-8"); const parsed = JSON.parse(raw) as unknown; @@ -48,7 +64,7 @@ async function runPluginAddResource(options: { path?: string }): Promise { ); process.exit(1); } - manifest = parsed as ManifestWithExtras; + return { manifest: parsed as ManifestWithExtras, manifestPath }; } catch (err) { console.error( "Failed to read or parse manifest.json:", @@ -56,6 +72,82 @@ async function runPluginAddResource(options: { path?: string }): Promise { ); process.exit(1); } +} + +function buildEntry( + type: string, + opts: AddResourceOptions, +): { entry: ResourceRequirement; isRequired: boolean } { + const alias = humanizeResourceType(type); + const isRequired = opts.required !== false; + + let fields = getDefaultFieldsForType(type); + if (opts.fieldsJson) { + try { + const parsed = JSON.parse(opts.fieldsJson) as Record< + string, + { env: string; description?: string } + >; + fields = { ...fields, ...parsed }; + } catch { + console.error("Error: --fields-json must be valid JSON."); + console.error( + ' Example: --fields-json \'{"id":{"env":"MY_WAREHOUSE_ID"}}\'', + ); + process.exit(1); + } + } + + const entry: ResourceRequirement = { + type: type as ResourceRequirement["type"], + alias, + resourceKey: opts.resourceKey ?? resourceKeyFromType(type), + description: + opts.description || + `${isRequired ? "Required" : "Optional"} for ${alias} functionality.`, + permission: + opts.permission ?? DEFAULT_PERMISSION_BY_TYPE[type] ?? "CAN_VIEW", + fields, + }; + + return { entry, isRequired }; +} + +function runNonInteractive(opts: AddResourceOptions): void { + const cwd = process.cwd(); + const pluginDir = path.resolve(cwd, opts.path ?? "."); + const loaded = loadManifest(pluginDir); + if (!loaded) return; + const { manifest, manifestPath } = loaded; + + const type = opts.type as string; + const { entry, isRequired } = buildEntry(type, opts); + + if (isRequired) { + manifest.resources.required.push(entry); + } else { + manifest.resources.optional.push(entry); + } + + if (opts.dryRun) { + console.log(JSON.stringify(manifest, null, 2)); + return; + } + + fs.writeFileSync(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`); + console.log( + `Added ${entry.alias} as ${isRequired ? "required" : "optional"} to ${path.relative(cwd, manifestPath)}`, + ); +} + +async function runInteractive(opts: AddResourceOptions): Promise { + intro("Add resource to plugin manifest"); + + const cwd = process.cwd(); + const pluginDir = path.resolve(cwd, opts.path ?? "."); + const loaded = loadManifest(pluginDir); + if (!loaded) return; + const { manifest, manifestPath } = loaded; const spec = await promptOneResource(); if (!spec) { @@ -65,8 +157,6 @@ async function runPluginAddResource(options: { path?: string }): Promise { const alias = humanizeResourceType(spec.type); const entry: ResourceRequirement = { - // Safe cast: spec.type comes from RESOURCE_TYPE_OPTIONS which reads values - // from the same JSON schema that generates the ResourceType union. type: spec.type as ResourceRequirement["type"], alias, resourceKey: spec.resourceKey, @@ -89,13 +179,44 @@ async function runPluginAddResource(options: { path?: string }): Promise { ); } +async function runPluginAddResource(opts: AddResourceOptions): Promise { + if (opts.type) { + runNonInteractive(opts); + } else { + await runInteractive(opts); + } +} + export const pluginAddResourceCommand = new Command("add-resource") .description( - "Add a resource requirement to an existing plugin manifest (interactive). Overwrites manifest.json in place.", + "Add a resource requirement to an existing plugin manifest. Overwrites manifest.json in place.", ) .option( "-p, --path ", - "Plugin directory containing manifest.json, which will be edited in place (default: .)", + "Plugin directory containing manifest.json (default: .)", + ) + .option( + "-t, --type ", + "Resource type (e.g. sql_warehouse, volume). Enables non-interactive mode.", + ) + .option("--required", "Mark resource as required (default: true)", true) + .option("--no-required", "Mark resource as optional") + .option("--resource-key ", "Resource key (default: derived from type)") + .option("--description ", "Description of the resource requirement") + .option("--permission ", "Permission level (default: from schema)") + .option( + "--fields-json ", + 'JSON object overriding field env vars (e.g. \'{"id":{"env":"MY_WAREHOUSE_ID"}}\')', + ) + .option("--dry-run", "Preview the updated manifest without writing") + .addHelpText( + "after", + ` +Examples: + $ appkit plugin add-resource + $ appkit plugin add-resource --path plugins/my-plugin --type sql_warehouse + $ appkit plugin add-resource --path plugins/my-plugin --type volume --no-required --dry-run + $ appkit plugin add-resource --type sql_warehouse --fields-json '{"id":{"env":"MY_WAREHOUSE_ID"}}'`, ) .action((opts) => runPluginAddResource(opts).catch((err) => { diff --git a/packages/shared/src/cli/commands/plugin/create/create.ts b/packages/shared/src/cli/commands/plugin/create/create.ts index 0be53af3..23f98d75 100644 --- a/packages/shared/src/cli/commands/plugin/create/create.ts +++ b/packages/shared/src/cli/commands/plugin/create/create.ts @@ -12,16 +12,205 @@ import { spinner, text, } from "@clack/prompts"; -import { Command } from "commander"; +import { Command, Option } from "commander"; import { promptOneResource } from "./prompt-resource"; -import { RESOURCE_TYPE_OPTIONS } from "./resource-defaults"; +import { + DEFAULT_PERMISSION_BY_TYPE, + getDefaultFieldsForType, + humanizeResourceType, + RESOURCE_TYPE_OPTIONS, + resourceKeyFromType, +} from "./resource-defaults"; import { resolveTargetDir, scaffoldPlugin } from "./scaffold"; -import type { CreateAnswers, Placement } from "./types"; +import type { CreateAnswers, Placement, SelectedResource } from "./types"; const NAME_PATTERN = /^[a-z][a-z0-9-]*$/; const DEFAULT_VERSION = "0.1.0"; +const VALID_PLACEMENTS: Placement[] = ["in-repo", "isolated"]; +const REQUIRED_FLAGS = ["placement", "path", "name", "description"] as const; + +interface CreateOptions { + placement?: string; + path?: string; + name?: string; + displayName?: string; + description?: string; + resources?: string; + resourcesJson?: string; + force?: boolean; +} + +function deriveDisplayName(name: string): string { + return name + .split("-") + .map((s) => s.charAt(0).toUpperCase() + s.slice(1)) + .join(" "); +} + +function deriveExportName(name: string): string { + return name + .split("-") + .map((s, i) => (i === 0 ? s : s.charAt(0).toUpperCase() + s.slice(1))) + .join(""); +} + +function buildResourceFromType(type: string): SelectedResource { + return { + type, + required: true, + description: `Required for ${humanizeResourceType(type)} functionality.`, + resourceKey: resourceKeyFromType(type), + permission: DEFAULT_PERMISSION_BY_TYPE[type] ?? "CAN_VIEW", + fields: getDefaultFieldsForType(type), + }; +} + +interface JsonResourceEntry { + type: string; + required?: boolean; + description?: string; + resourceKey?: string; + permission?: string; + fields?: Record; +} + +function parseResourcesJson(json: string): SelectedResource[] { + let parsed: unknown; + try { + parsed = JSON.parse(json); + } catch { + console.error("Error: --resources-json must be valid JSON."); + console.error(' Example: --resources-json \'[{"type":"sql_warehouse"}]\''); + process.exit(1); + } + + if (!Array.isArray(parsed)) { + console.error("Error: --resources-json must be a JSON array."); + console.error(' Example: --resources-json \'[{"type":"sql_warehouse"}]\''); + process.exit(1); + } + + return (parsed as JsonResourceEntry[]).map((entry, i) => { + if (!entry.type || typeof entry.type !== "string") { + console.error( + `Error: --resources-json entry ${i} missing required "type" field.`, + ); + process.exit(1); + } + const defaults = buildResourceFromType(entry.type); + return { + type: entry.type, + required: entry.required ?? defaults.required, + description: entry.description ?? defaults.description, + resourceKey: entry.resourceKey ?? defaults.resourceKey, + permission: entry.permission ?? defaults.permission, + fields: entry.fields ?? defaults.fields, + }; + }); +} + +function parseResourcesShorthand(csv: string): SelectedResource[] { + return csv + .split(",") + .map((s) => s.trim()) + .filter(Boolean) + .map(buildResourceFromType); +} + +function printNextSteps(answers: CreateAnswers, targetDir: string): void { + const relativePath = path.relative(process.cwd(), targetDir); + const importPath = relativePath.startsWith(".") + ? relativePath + : `./${relativePath}`; + const exportName = deriveExportName(answers.name); + + console.log("\nNext steps:\n"); + if (answers.placement === "in-repo") { + console.log(` 1. Import and register in your server:`); + console.log(` import { ${exportName} } from "${importPath}";`); + console.log(` createApp({ plugins: [ ..., ${exportName}() ] });`); + console.log( + ` 2. Run \`npx appkit plugin sync --write\` to update appkit.plugins.json.\n`, + ); + } else { + console.log(` 1. cd into the new package and install dependencies:`); + console.log(` cd ${answers.targetPath} && pnpm install`); + console.log(` 2. Build: pnpm build`); + console.log( + ` 3. In your app: pnpm add ./${answers.targetPath} @databricks/appkit`, + ); + console.log( + ` 4. Import and register: import { ${exportName} } from "";\n`, + ); + } +} + +function runNonInteractive(opts: CreateOptions): void { + const missing = REQUIRED_FLAGS.filter((f) => !opts[f]); + if (missing.length > 0) { + console.error( + `Error: Non-interactive mode requires: ${REQUIRED_FLAGS.map((f) => `--${f}`).join(", ")}`, + ); + console.error(`Missing: ${missing.map((f) => `--${f}`).join(", ")}`); + console.error( + ' appkit plugin create --placement in-repo --path plugins/my-plugin --name my-plugin --description "Does X"', + ); + process.exit(1); + } + + const placement = opts.placement as Placement; + if (!VALID_PLACEMENTS.includes(placement)) { + console.error( + `Error: --placement must be one of: ${VALID_PLACEMENTS.join(", ")}`, + ); + process.exit(1); + } + + const name = opts.name as string; + if (!NAME_PATTERN.test(name)) { + console.error( + "Error: --name must be lowercase, start with a letter, and use only letters, numbers, and hyphens.", + ); + process.exit(1); + } + + let resources: SelectedResource[] = []; + if (opts.resourcesJson) { + resources = parseResourcesJson(opts.resourcesJson); + } else if (opts.resources) { + resources = parseResourcesShorthand(opts.resources); + } + + const answers: CreateAnswers = { + placement, + targetPath: (opts.path as string).trim(), + name: name.trim(), + displayName: opts.displayName?.trim() || deriveDisplayName(name), + description: (opts.description as string).trim(), + resources, + version: DEFAULT_VERSION, + }; + + const targetDir = resolveTargetDir(process.cwd(), answers); + const dirExists = fs.existsSync(targetDir); + const hasContent = dirExists && fs.readdirSync(targetDir).length > 0; + if (hasContent && !opts.force) { + console.error( + `Error: Directory ${answers.targetPath} already exists and is not empty.`, + ); + console.error(" Use --force to overwrite."); + process.exit(1); + } -async function runPluginCreate(): Promise { + scaffoldPlugin(targetDir, answers, { isolated: placement === "isolated" }); + + console.log( + `Plugin "${answers.name}" created at ${path.relative(process.cwd(), targetDir)}`, + ); + printNextSteps(answers, targetDir); +} + +async function runInteractive(): Promise { intro("Create a new AppKit plugin"); try { @@ -183,42 +372,55 @@ async function runPluginCreate(): Promise { throw err; } - const relativePath = path.relative(process.cwd(), targetDir); - const importPath = relativePath.startsWith(".") - ? relativePath - : `./${relativePath}`; - const exportName = answers.name - .split("-") - .map((s, i) => (i === 0 ? s : s.charAt(0).toUpperCase() + s.slice(1))) - .join(""); - outro("Plugin created successfully."); - - console.log("\nNext steps:\n"); - if (placement === "in-repo") { - console.log(` 1. Import and register in your server:`); - console.log(` import { ${exportName} } from "${importPath}";`); - console.log(` createApp({ plugins: [ ..., ${exportName}() ] });`); - console.log( - ` 2. Run \`npx appkit plugin sync --write\` to update appkit.plugins.json.\n`, - ); - } else { - console.log(` 1. cd into the new package and install dependencies:`); - console.log(` cd ${answers.targetPath} && pnpm install`); - console.log(` 2. Build: pnpm build`); - console.log( - ` 3. In your app: pnpm add ./${answers.targetPath} @databricks/appkit`, - ); - console.log( - ` 4. Import and register: import { ${exportName} } from "";\n`, - ); - } + printNextSteps(answers, targetDir); } catch (err) { console.error(err); process.exit(1); } } +async function runPluginCreate(opts: CreateOptions): Promise { + const hasAnyFlag = REQUIRED_FLAGS.some((f) => opts[f] !== undefined); + if (hasAnyFlag) { + runNonInteractive(opts); + } else { + await runInteractive(); + } +} + export const pluginCreateCommand = new Command("create") - .description("Scaffold a new AppKit plugin (interactive)") - .action(runPluginCreate); + .description("Scaffold a new AppKit plugin") + .option("--placement ", "Where the plugin lives (in-repo, isolated)") + .option("--path ", "Target directory for the plugin") + .option("--name ", "Plugin name (lowercase, hyphens allowed)") + .option("--display-name ", "Human-readable display name") + .option("--description ", "Short description of the plugin") + .addOption( + new Option( + "--resources ", + "Comma-separated resource types (e.g. sql_warehouse,volume)", + ).conflicts("resourcesJson"), + ) + .addOption( + new Option( + "--resources-json ", + 'JSON array of resource specs (e.g. \'[{"type":"sql_warehouse"}]\')', + ).conflicts("resources"), + ) + .option("-f, --force", "Overwrite existing directory without confirmation") + .addHelpText( + "after", + ` +Examples: + $ appkit plugin create + $ appkit plugin create --placement in-repo --path plugins/my-plugin --name my-plugin --description "Does X" + $ appkit plugin create --placement in-repo --path plugins/my-plugin --name my-plugin --description "Does X" --resources sql_warehouse,volume --force + $ appkit plugin create --placement isolated --path appkit-plugin-ml --name ml --description "ML" --resources-json '[{"type":"serving_endpoint"}]'`, + ) + .action((opts) => + runPluginCreate(opts).catch((err) => { + console.error(err); + process.exit(1); + }), + ); diff --git a/packages/shared/src/cli/commands/plugin/index.ts b/packages/shared/src/cli/commands/plugin/index.ts index 04b8cba9..d2ff00a9 100644 --- a/packages/shared/src/cli/commands/plugin/index.ts +++ b/packages/shared/src/cli/commands/plugin/index.ts @@ -20,4 +20,14 @@ export const pluginCommand = new Command("plugin") .addCommand(pluginCreateCommand) .addCommand(pluginValidateCommand) .addCommand(pluginListCommand) - .addCommand(pluginAddResourceCommand); + .addCommand(pluginAddResourceCommand) + .addHelpText( + "after", + ` +Examples: + $ appkit plugin sync --write + $ appkit plugin create --placement in-repo --path plugins/my-plugin --name my-plugin --description "Does X" + $ appkit plugin validate . + $ appkit plugin list --json + $ appkit plugin add-resource --path plugins/my-plugin --type sql_warehouse`, + ); diff --git a/packages/shared/src/cli/commands/plugin/list/list.ts b/packages/shared/src/cli/commands/plugin/list/list.ts index d9bdc206..f4a3f725 100644 --- a/packages/shared/src/cli/commands/plugin/list/list.ts +++ b/packages/shared/src/cli/commands/plugin/list/list.ts @@ -238,6 +238,15 @@ export const pluginListCommand = new Command("list") "Allow reading manifest.js/manifest.cjs (executes code; use only with trusted plugins)", ) .option("--json", "Output as JSON") + .addHelpText( + "after", + ` +Examples: + $ appkit plugin list + $ appkit plugin list --json + $ appkit plugin list --manifest custom-manifest.json + $ appkit plugin list --dir plugins/`, + ) .action((opts) => runPluginList(opts).catch((err) => { console.error(err); diff --git a/packages/shared/src/cli/commands/plugin/sync/sync.ts b/packages/shared/src/cli/commands/plugin/sync/sync.ts index b553c45a..810e01ab 100644 --- a/packages/shared/src/cli/commands/plugin/sync/sync.ts +++ b/packages/shared/src/cli/commands/plugin/sync/sync.ts @@ -802,6 +802,16 @@ export const pluginsSyncCommand = new Command("sync") "--allow-js-manifest", "Allow reading manifest.js/manifest.cjs (executes code; use only with trusted plugins)", ) + .addHelpText( + "after", + ` +Examples: + $ appkit plugin sync + $ appkit plugin sync --write + $ appkit plugin sync --write --require-plugins server,analytics + $ appkit plugin sync --write --plugins-dir src/plugins --package-name @my/pkg + $ appkit plugin sync --silent`, + ) .action((opts) => runPluginsSync(opts).catch((err) => { console.error(err); diff --git a/packages/shared/src/cli/commands/plugin/validate/validate.ts b/packages/shared/src/cli/commands/plugin/validate/validate.ts index 76ccfbae..e3a6c1e1 100644 --- a/packages/shared/src/cli/commands/plugin/validate/validate.ts +++ b/packages/shared/src/cli/commands/plugin/validate/validate.ts @@ -127,6 +127,15 @@ export const pluginValidateCommand = new Command("validate") "--allow-js-manifest", "Allow reading manifest.js/manifest.cjs (executes code; use only with trusted plugins)", ) + .addHelpText( + "after", + ` +Examples: + $ appkit plugin validate + $ appkit plugin validate plugins/my-plugin + $ appkit plugin validate plugins/my-plugin plugins/other + $ appkit plugin validate appkit.plugins.json`, + ) .action((paths: string[], opts: { allowJsManifest?: boolean }) => runPluginValidate(paths, opts).catch((err) => { console.error(err); diff --git a/packages/shared/src/cli/commands/setup.ts b/packages/shared/src/cli/commands/setup.ts index 174c233e..2076dbaa 100644 --- a/packages/shared/src/cli/commands/setup.ts +++ b/packages/shared/src/cli/commands/setup.ts @@ -182,4 +182,11 @@ function runSetup(options: { write?: boolean }) { export const setupCommand = new Command("setup") .description("Setup CLAUDE.md with AppKit package references") .option("-w, --write", "Create or update CLAUDE.md file in current directory") + .addHelpText( + "after", + ` +Examples: + $ appkit setup + $ appkit setup --write`, + ) .action(runSetup); From 4eda8612855f949a7ed86aa3da12c4afa1006390 Mon Sep 17 00:00:00 2001 From: MarioCadenas Date: Tue, 7 Apr 2026 12:41:50 +0200 Subject: [PATCH 2/6] feat(cli): improve error messages with corrective invocations - generate-types: print info message to stderr when warehouse ID is missing (exit 0 preserved for template hook compatibility), add success message after generation - list: suggest --manifest or --dir when manifest not found - setup: show install commands when no packages found - sync: suggest --plugins-dir when no plugins discovered Signed-off-by: MarioCadenas --- packages/shared/src/cli/commands/generate-types.ts | 12 +++++++++++- packages/shared/src/cli/commands/plugin/list/list.ts | 3 +++ packages/shared/src/cli/commands/plugin/sync/sync.ts | 5 ++++- packages/shared/src/cli/commands/setup.ts | 8 ++++---- 4 files changed, 22 insertions(+), 6 deletions(-) diff --git a/packages/shared/src/cli/commands/generate-types.ts b/packages/shared/src/cli/commands/generate-types.ts index a11680cd..ac3fe75c 100644 --- a/packages/shared/src/cli/commands/generate-types.ts +++ b/packages/shared/src/cli/commands/generate-types.ts @@ -33,14 +33,24 @@ async function runGenerateTypes( warehouseId: resolvedWarehouseId, noCache, }); + console.log(`Generated query types: ${resolvedOutFile}`); } + } else { + console.error( + "Skipping query type generation: no warehouse ID. Set DATABRICKS_WAREHOUSE_ID or pass as argument.", + ); } // Generate serving endpoint types (no warehouse required) + const servingOutFile = path.join( + process.cwd(), + "client/src/appKitServingTypes.d.ts", + ); await typeGen.generateServingTypes({ - outFile: path.join(process.cwd(), "client/src/appKitServingTypes.d.ts"), + outFile: servingOutFile, noCache, }); + console.log(`Generated serving types: ${servingOutFile}`); } catch (error) { if ( error instanceof Error && diff --git a/packages/shared/src/cli/commands/plugin/list/list.ts b/packages/shared/src/cli/commands/plugin/list/list.ts index f4a3f725..e9a3b35e 100644 --- a/packages/shared/src/cli/commands/plugin/list/list.ts +++ b/packages/shared/src/cli/commands/plugin/list/list.ts @@ -205,6 +205,9 @@ async function runPluginList(options: { ); if (!fs.existsSync(manifestPath)) { console.error(`Manifest not found: ${manifestPath}`); + console.error( + " appkit plugin list --manifest or appkit plugin list --dir ", + ); process.exit(1); } try { diff --git a/packages/shared/src/cli/commands/plugin/sync/sync.ts b/packages/shared/src/cli/commands/plugin/sync/sync.ts index 810e01ab..f763b375 100644 --- a/packages/shared/src/cli/commands/plugin/sync/sync.ts +++ b/packages/shared/src/cli/commands/plugin/sync/sync.ts @@ -689,7 +689,10 @@ async function runPluginsSync(options: { `\nNo manifest (${allowJsManifest ? "manifest.json or manifest.js" : "manifest.json"}) found in: ${options.pluginsDir}`, ); } else { - console.log("\nMake sure you have plugin packages installed."); + console.log( + "\nMake sure you have plugin packages installed, or specify a directory:", + ); + console.log(" appkit plugin sync --plugins-dir "); } process.exit(1); } diff --git a/packages/shared/src/cli/commands/setup.ts b/packages/shared/src/cli/commands/setup.ts index 2076dbaa..339c2c3a 100644 --- a/packages/shared/src/cli/commands/setup.ts +++ b/packages/shared/src/cli/commands/setup.ts @@ -126,10 +126,10 @@ function runSetup(options: { write?: boolean }) { if (installed.length === 0) { console.log("No @databricks/appkit packages found in node_modules."); - console.log("\nMake sure you've installed at least one of:"); - PACKAGES.forEach((pkg) => { - console.log(` - ${pkg.name}`); - }); + console.log("\nInstall at least one of:"); + for (const pkg of PACKAGES) { + console.log(` npm install ${pkg.name}`); + } process.exit(1); } From 710eb3d3d9efc4c4a981ed8c929ded1816d5ffdb Mon Sep 17 00:00:00 2001 From: MarioCadenas Date: Tue, 7 Apr 2026 12:45:56 +0200 Subject: [PATCH 3/6] feat(cli): add --json output for plugin sync and validate - sync --json: outputs the full TemplatePluginsManifest JSON to stdout, suppresses human-readable progress lines (works in both preview and write modes) - validate --json: outputs status-only JSON array with path, valid, and errors fields; exit code still reflects overall validity Signed-off-by: MarioCadenas --- .../src/cli/commands/plugin/sync/sync.ts | 25 ++++--- .../cli/commands/plugin/validate/validate.ts | 65 +++++++++++++++---- 2 files changed, 69 insertions(+), 21 deletions(-) diff --git a/packages/shared/src/cli/commands/plugin/sync/sync.ts b/packages/shared/src/cli/commands/plugin/sync/sync.ts index f763b375..4b2c21f0 100644 --- a/packages/shared/src/cli/commands/plugin/sync/sync.ts +++ b/packages/shared/src/cli/commands/plugin/sync/sync.ts @@ -520,7 +520,7 @@ async function scanPluginsDir( function writeManifest( outputPath: string, { plugins }: { plugins: TemplatePluginsManifest["plugins"] }, - options: { write?: boolean; silent?: boolean }, + options: { write?: boolean; silent?: boolean; json?: boolean }, ) { const templateManifest: TemplatePluginsManifest = { $schema: @@ -529,15 +529,19 @@ function writeManifest( plugins, }; + if (options.json) { + console.log(JSON.stringify(templateManifest, null, 2)); + } + if (options.write) { fs.writeFileSync( outputPath, `${JSON.stringify(templateManifest, null, 2)}\n`, ); - if (!options.silent) { + if (!options.silent && !options.json) { console.log(`\n✓ Wrote ${outputPath}`); } - } else if (!options.silent) { + } else if (!options.silent && !options.json) { console.log("\nTo write the manifest, run:"); console.log(" npx appkit plugin sync --write\n"); console.log("Preview:"); @@ -557,6 +561,7 @@ async function runPluginsSync(options: { write?: boolean; output?: string; silent?: boolean; + json?: boolean; requirePlugins?: string; pluginsDir?: string; packageName?: string; @@ -575,7 +580,7 @@ async function runPluginsSync(options: { process.exit(1); } - if (!options.silent) { + if (!options.silent && !options.json) { console.log("Scanning for AppKit plugins...\n"); if (allowJsManifest) { console.warn( @@ -590,7 +595,7 @@ async function runPluginsSync(options: { let pluginUsages = new Set(); if (serverFile) { - if (!options.silent) { + if (!options.silent && !options.json) { const relativePath = path.relative(cwd, serverFile); console.log(`Server entry file: ${relativePath}`); } @@ -602,7 +607,7 @@ async function runPluginsSync(options: { serverImports = parseImports(root); pluginUsages = parsePluginUsages(root); - } else if (!options.silent) { + } else if (!options.silent && !options.json) { console.log( "No server entry file found. Checked:", SERVER_FILE_CANDIDATES.join(", "), @@ -623,7 +628,7 @@ async function runPluginsSync(options: { if (options.pluginsDir) { const resolvedDir = path.resolve(cwd, options.pluginsDir); const pkgName = options.packageName ?? "@databricks/appkit"; - if (!options.silent) { + if (!options.silent && !options.json) { console.log(`Scanning plugins directory: ${options.pluginsDir}`); } Object.assign( @@ -663,7 +668,7 @@ async function runPluginsSync(options: { for (const dir of localDirsToScan) { const resolvedDir = path.resolve(cwd, dir); if (!fs.existsSync(resolvedDir)) continue; - if (!options.silent) { + if (!options.silent && !options.json) { console.log(`Scanning local plugins directory: ${dir}`); } const discovered = await scanPluginsDirRecursive( @@ -747,7 +752,7 @@ async function runPluginsSync(options: { } } - if (!options.silent) { + if (!options.silent && !options.json) { console.log(`\nFound ${pluginCount} plugin(s):`); for (const [name, manifest] of Object.entries(plugins)) { const resourceCount = @@ -805,6 +810,7 @@ export const pluginsSyncCommand = new Command("sync") "--allow-js-manifest", "Allow reading manifest.js/manifest.cjs (executes code; use only with trusted plugins)", ) + .option("--json", "Output manifest as JSON to stdout") .addHelpText( "after", ` @@ -813,6 +819,7 @@ Examples: $ appkit plugin sync --write $ appkit plugin sync --write --require-plugins server,analytics $ appkit plugin sync --write --plugins-dir src/plugins --package-name @my/pkg + $ appkit plugin sync --json $ appkit plugin sync --silent`, ) .action((opts) => diff --git a/packages/shared/src/cli/commands/plugin/validate/validate.ts b/packages/shared/src/cli/commands/plugin/validate/validate.ts index e3a6c1e1..59ec7b7a 100644 --- a/packages/shared/src/cli/commands/plugin/validate/validate.ts +++ b/packages/shared/src/cli/commands/plugin/validate/validate.ts @@ -63,13 +63,18 @@ function resolveManifestPaths( return out; } +interface ValidateOptions { + allowJsManifest?: boolean; + json?: boolean; +} + async function runPluginValidate( paths: string[], - options: { allowJsManifest?: boolean }, + options: ValidateOptions, ): Promise { const cwd = process.cwd(); const allowJsManifest = Boolean(options.allowJsManifest); - if (allowJsManifest) { + if (allowJsManifest && !options.json) { console.warn( "Warning: --allow-js-manifest executes manifest.js/manifest.cjs files. Only use with trusted code.", ); @@ -78,18 +83,34 @@ async function runPluginValidate( const manifestPaths = resolveManifestPaths(toValidate, cwd, allowJsManifest); if (manifestPaths.length === 0) { - console.error("No manifest files to validate."); + if (options.json) { + console.log("[]"); + } else { + console.error("No manifest files to validate."); + } process.exit(1); } let hasFailure = false; + const jsonResults: { path: string; valid: boolean; errors?: string[] }[] = []; + for (const { path: manifestPath, type } of manifestPaths) { + const relativePath = path.relative(cwd, manifestPath); let obj: unknown; try { obj = await loadManifestFromFile(manifestPath, type, { allowJsManifest }); } catch (err) { - console.error(`✗ ${manifestPath}`); - console.error(` ${err instanceof Error ? err.message : String(err)}`); + const errMsg = err instanceof Error ? err.message : String(err); + if (options.json) { + jsonResults.push({ + path: relativePath, + valid: false, + errors: [errMsg], + }); + } else { + console.error(`✗ ${manifestPath}`); + console.error(` ${errMsg}`); + } hasFailure = true; continue; } @@ -100,18 +121,36 @@ async function runPluginValidate( ? validateTemplateManifest(obj) : validateManifest(obj); - const relativePath = path.relative(cwd, manifestPath); if (result.valid) { - console.log(`✓ ${relativePath}`); + if (options.json) { + jsonResults.push({ path: relativePath, valid: true }); + } else { + console.log(`✓ ${relativePath}`); + } } else { - console.error(`✗ ${relativePath}`); - if (result.errors?.length) { - console.error(formatValidationErrors(result.errors, obj)); + const errors = result.errors?.length + ? formatValidationErrors(result.errors, obj).split("\n").filter(Boolean) + : []; + if (options.json) { + jsonResults.push({ + path: relativePath, + valid: false, + ...(errors.length > 0 && { errors }), + }); + } else { + console.error(`✗ ${relativePath}`); + if (result.errors?.length) { + console.error(formatValidationErrors(result.errors, obj)); + } } hasFailure = true; } } + if (options.json) { + console.log(JSON.stringify(jsonResults, null, 2)); + } + process.exit(hasFailure ? 1 : 0); } @@ -127,6 +166,7 @@ export const pluginValidateCommand = new Command("validate") "--allow-js-manifest", "Allow reading manifest.js/manifest.cjs (executes code; use only with trusted plugins)", ) + .option("--json", "Output validation results as JSON") .addHelpText( "after", ` @@ -134,9 +174,10 @@ Examples: $ appkit plugin validate $ appkit plugin validate plugins/my-plugin $ appkit plugin validate plugins/my-plugin plugins/other - $ appkit plugin validate appkit.plugins.json`, + $ appkit plugin validate appkit.plugins.json + $ appkit plugin validate --json`, ) - .action((paths: string[], opts: { allowJsManifest?: boolean }) => + .action((paths: string[], opts: ValidateOptions) => runPluginValidate(paths, opts).catch((err) => { console.error(err); process.exit(1); From 489588cdcc7d52f4211ce7db91663fc4bd4e6862 Mon Sep 17 00:00:00 2001 From: MarioCadenas Date: Tue, 7 Apr 2026 12:50:34 +0200 Subject: [PATCH 4/6] test(shared): add tests for non-interactive plugin create helpers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Test buildResourceFromType, parseResourcesShorthand, and parseResourcesJson — the pure functions backing the new flag-based non-interactive path for `appkit plugin create`. Signed-off-by: MarioCadenas --- .../cli/commands/plugin/create/create.test.ts | 99 +++++++++++++++++++ .../src/cli/commands/plugin/create/create.ts | 3 + 2 files changed, 102 insertions(+) create mode 100644 packages/shared/src/cli/commands/plugin/create/create.test.ts diff --git a/packages/shared/src/cli/commands/plugin/create/create.test.ts b/packages/shared/src/cli/commands/plugin/create/create.test.ts new file mode 100644 index 00000000..19ec7cd2 --- /dev/null +++ b/packages/shared/src/cli/commands/plugin/create/create.test.ts @@ -0,0 +1,99 @@ +import { describe, expect, it } from "vitest"; +import { + buildResourceFromType, + parseResourcesJson, + parseResourcesShorthand, +} from "./create"; + +describe("create non-interactive helpers", () => { + describe("buildResourceFromType", () => { + it("builds a sql_warehouse resource with correct defaults", () => { + const resource = buildResourceFromType("sql_warehouse"); + expect(resource.type).toBe("sql_warehouse"); + expect(resource.required).toBe(true); + expect(resource.resourceKey).toBe("sql-warehouse"); + expect(resource.permission).toBe("CAN_USE"); + expect(resource.fields.id.env).toBe("DATABRICKS_WAREHOUSE_ID"); + }); + + it("builds a volume resource with correct defaults", () => { + const resource = buildResourceFromType("volume"); + expect(resource.type).toBe("volume"); + expect(resource.resourceKey).toBe("volume"); + expect(resource.fields.name.env).toBe("VOLUME_NAME"); + }); + + it("builds an unknown type with a fallback pattern", () => { + const resource = buildResourceFromType("custom_thing"); + expect(resource.type).toBe("custom_thing"); + expect(resource.resourceKey).toBe("custom-thing"); + expect(resource.permission).toBe("CAN_VIEW"); + expect(resource.fields.id.env).toBe("DATABRICKS_CUSTOM_THING_ID"); + }); + }); + + describe("parseResourcesShorthand", () => { + it("parses comma-separated resource types", () => { + const resources = parseResourcesShorthand("sql_warehouse,volume"); + expect(resources).toHaveLength(2); + expect(resources[0].type).toBe("sql_warehouse"); + expect(resources[1].type).toBe("volume"); + }); + + it("trims whitespace around types", () => { + const resources = parseResourcesShorthand(" sql_warehouse , volume "); + expect(resources).toHaveLength(2); + expect(resources[0].type).toBe("sql_warehouse"); + expect(resources[1].type).toBe("volume"); + }); + + it("filters empty segments", () => { + const resources = parseResourcesShorthand("sql_warehouse,,volume,"); + expect(resources).toHaveLength(2); + }); + + it("returns empty array for empty string", () => { + const resources = parseResourcesShorthand(""); + expect(resources).toHaveLength(0); + }); + }); + + describe("parseResourcesJson", () => { + it("parses minimal JSON with only type", () => { + const resources = parseResourcesJson('[{"type":"sql_warehouse"}]'); + expect(resources).toHaveLength(1); + expect(resources[0].type).toBe("sql_warehouse"); + expect(resources[0].required).toBe(true); + expect(resources[0].permission).toBe("CAN_USE"); + expect(resources[0].resourceKey).toBe("sql-warehouse"); + }); + + it("allows overriding individual fields", () => { + const json = JSON.stringify([ + { + type: "sql_warehouse", + required: false, + permission: "CAN_MANAGE", + description: "Custom desc", + }, + ]); + const resources = parseResourcesJson(json); + expect(resources[0].required).toBe(false); + expect(resources[0].permission).toBe("CAN_MANAGE"); + expect(resources[0].description).toBe("Custom desc"); + expect(resources[0].resourceKey).toBe("sql-warehouse"); + }); + + it("parses multiple resources", () => { + const json = JSON.stringify([ + { type: "sql_warehouse" }, + { type: "volume", required: false }, + ]); + const resources = parseResourcesJson(json); + expect(resources).toHaveLength(2); + expect(resources[0].type).toBe("sql_warehouse"); + expect(resources[1].type).toBe("volume"); + expect(resources[1].required).toBe(false); + }); + }); +}); diff --git a/packages/shared/src/cli/commands/plugin/create/create.ts b/packages/shared/src/cli/commands/plugin/create/create.ts index 23f98d75..3003b281 100644 --- a/packages/shared/src/cli/commands/plugin/create/create.ts +++ b/packages/shared/src/cli/commands/plugin/create/create.ts @@ -424,3 +424,6 @@ Examples: process.exit(1); }), ); + +/** Exported for testing. */ +export { buildResourceFromType, parseResourcesJson, parseResourcesShorthand }; From 113c4ce08ef98a112377c9e9253bf25b9475a1a9 Mon Sep 17 00:00:00 2001 From: MarioCadenas Date: Fri, 10 Apr 2026 17:38:10 +0200 Subject: [PATCH 5/6] fix(cli): harden non-interactive CLI paths from review findings - sync --json: output valid JSON when zero plugins found instead of human text that breaks JSON.parse in agent pipelines - create: guard against null/non-object entries in --resources-json - create + add-resource: validate --type against schema's resourceType enum before building entries, preventing manifest corruption - create: reject absolute/parent paths in non-interactive in-repo mode, matching the interactive mode's existing validation - create: error when optional-only flags (--resources, --force) are provided without required flags instead of silently falling through to interactive mode - sync: reuse serialized JSON string instead of double JSON.stringify - validate: avoid redundant formatValidationErrors call - tests: add cases for null guard, unknown type validation Signed-off-by: MarioCadenas --- .../plugin/add-resource/add-resource.ts | 7 +++ .../cli/commands/plugin/create/create.test.ts | 55 +++++++++++++++++- .../src/cli/commands/plugin/create/create.ts | 58 +++++++++++++++++-- .../plugin/create/resource-defaults.ts | 5 ++ .../src/cli/commands/plugin/sync/sync.ts | 16 ++--- .../cli/commands/plugin/validate/validate.ts | 8 ++- 6 files changed, 131 insertions(+), 18 deletions(-) diff --git a/packages/shared/src/cli/commands/plugin/add-resource/add-resource.ts b/packages/shared/src/cli/commands/plugin/add-resource/add-resource.ts index dc1a327e..62170098 100644 --- a/packages/shared/src/cli/commands/plugin/add-resource/add-resource.ts +++ b/packages/shared/src/cli/commands/plugin/add-resource/add-resource.ts @@ -7,6 +7,7 @@ import { promptOneResource } from "../create/prompt-resource"; import { DEFAULT_PERMISSION_BY_TYPE, getDefaultFieldsForType, + getValidResourceTypes, humanizeResourceType, resourceKeyFromType, } from "../create/resource-defaults"; @@ -121,6 +122,12 @@ function runNonInteractive(opts: AddResourceOptions): void { const { manifest, manifestPath } = loaded; const type = opts.type as string; + const validTypes = getValidResourceTypes(); + if (!validTypes.includes(type)) { + console.error(`Error: Unknown resource type "${type}".`); + console.error(` Valid types: ${validTypes.join(", ")}`); + process.exit(1); + } const { entry, isRequired } = buildEntry(type, opts); if (isRequired) { diff --git a/packages/shared/src/cli/commands/plugin/create/create.test.ts b/packages/shared/src/cli/commands/plugin/create/create.test.ts index 19ec7cd2..0b498294 100644 --- a/packages/shared/src/cli/commands/plugin/create/create.test.ts +++ b/packages/shared/src/cli/commands/plugin/create/create.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it } from "vitest"; +import { describe, expect, it, vi } from "vitest"; import { buildResourceFromType, parseResourcesJson, @@ -95,5 +95,58 @@ describe("create non-interactive helpers", () => { expect(resources[1].type).toBe("volume"); expect(resources[1].required).toBe(false); }); + + it("exits on null entries in the array", () => { + const stderrSpy = vi.spyOn(console, "error").mockImplementation(() => {}); + vi.spyOn(process, "exit").mockImplementation((code) => { + throw new Error(`process.exit(${code})`); + }); + try { + expect(() => parseResourcesJson('[null, {"type":"volume"}]')).toThrow( + "process.exit(1)", + ); + expect(stderrSpy).toHaveBeenCalledWith( + expect.stringContaining("entry 0 is not an object"), + ); + } finally { + vi.restoreAllMocks(); + } + }); + + it("exits on unknown resource type", () => { + const stderrSpy = vi.spyOn(console, "error").mockImplementation(() => {}); + vi.spyOn(process, "exit").mockImplementation((code) => { + throw new Error(`process.exit(${code})`); + }); + try { + expect(() => + parseResourcesJson('[{"type":"not_a_real_type"}]'), + ).toThrow("process.exit(1)"); + expect(stderrSpy).toHaveBeenCalledWith( + expect.stringContaining('Unknown resource type "not_a_real_type"'), + ); + } finally { + vi.restoreAllMocks(); + } + }); + }); + + describe("parseResourcesShorthand", () => { + it("exits on unknown resource type", () => { + const stderrSpy = vi.spyOn(console, "error").mockImplementation(() => {}); + vi.spyOn(process, "exit").mockImplementation((code) => { + throw new Error(`process.exit(${code})`); + }); + try { + expect(() => + parseResourcesShorthand("sql_warehouse,fake_type"), + ).toThrow("process.exit(1)"); + expect(stderrSpy).toHaveBeenCalledWith( + expect.stringContaining('Unknown resource type "fake_type"'), + ); + } finally { + vi.restoreAllMocks(); + } + }); }); }); diff --git a/packages/shared/src/cli/commands/plugin/create/create.ts b/packages/shared/src/cli/commands/plugin/create/create.ts index 3003b281..2d516bed 100644 --- a/packages/shared/src/cli/commands/plugin/create/create.ts +++ b/packages/shared/src/cli/commands/plugin/create/create.ts @@ -17,6 +17,7 @@ import { promptOneResource } from "./prompt-resource"; import { DEFAULT_PERMISSION_BY_TYPE, getDefaultFieldsForType, + getValidResourceTypes, humanizeResourceType, RESOURCE_TYPE_OPTIONS, resourceKeyFromType, @@ -91,12 +92,17 @@ function parseResourcesJson(json: string): SelectedResource[] { } return (parsed as JsonResourceEntry[]).map((entry, i) => { + if (entry == null || typeof entry !== "object") { + console.error(`Error: --resources-json entry ${i} is not an object.`); + process.exit(1); + } if (!entry.type || typeof entry.type !== "string") { console.error( `Error: --resources-json entry ${i} missing required "type" field.`, ); process.exit(1); } + validateResourceType(entry.type); const defaults = buildResourceFromType(entry.type); return { type: entry.type, @@ -109,12 +115,22 @@ function parseResourcesJson(json: string): SelectedResource[] { }); } +function validateResourceType(type: string): void { + const validTypes = getValidResourceTypes(); + if (!validTypes.includes(type)) { + console.error(`Error: Unknown resource type "${type}".`); + console.error(` Valid types: ${validTypes.join(", ")}`); + process.exit(1); + } +} + function parseResourcesShorthand(csv: string): SelectedResource[] { - return csv + const types = csv .split(",") .map((s) => s.trim()) - .filter(Boolean) - .map(buildResourceFromType); + .filter(Boolean); + for (const t of types) validateResourceType(t); + return types.map(buildResourceFromType); } function printNextSteps(answers: CreateAnswers, targetDir: string): void { @@ -166,6 +182,17 @@ function runNonInteractive(opts: CreateOptions): void { process.exit(1); } + const targetPath = (opts.path as string).trim(); + if ( + placement === "in-repo" && + (path.isAbsolute(targetPath) || targetPath.startsWith("..")) + ) { + console.error( + "Error: --path must be a relative path under the current directory for in-repo plugins.", + ); + process.exit(1); + } + const name = opts.name as string; if (!NAME_PATTERN.test(name)) { console.error( @@ -183,7 +210,7 @@ function runNonInteractive(opts: CreateOptions): void { const answers: CreateAnswers = { placement, - targetPath: (opts.path as string).trim(), + targetPath, name: name.trim(), displayName: opts.displayName?.trim() || deriveDisplayName(name), description: (opts.description as string).trim(), @@ -380,11 +407,30 @@ async function runInteractive(): Promise { } } +const OPTIONAL_FLAGS = [ + "displayName", + "resources", + "resourcesJson", + "force", +] as const; + async function runPluginCreate(opts: CreateOptions): Promise { - const hasAnyFlag = REQUIRED_FLAGS.some((f) => opts[f] !== undefined); - if (hasAnyFlag) { + const hasRequiredFlag = REQUIRED_FLAGS.some((f) => opts[f] !== undefined); + if (hasRequiredFlag) { runNonInteractive(opts); } else { + const hasOptionalOnly = OPTIONAL_FLAGS.some( + (f) => opts[f] !== undefined && opts[f] !== false, + ); + if (hasOptionalOnly) { + console.error( + `Error: Non-interactive mode requires: ${REQUIRED_FLAGS.map((f) => `--${f}`).join(", ")}`, + ); + console.error( + ' appkit plugin create --placement in-repo --path plugins/my-plugin --name my-plugin --description "Does X"', + ); + process.exit(1); + } await runInteractive(); } } diff --git a/packages/shared/src/cli/commands/plugin/create/resource-defaults.ts b/packages/shared/src/cli/commands/plugin/create/resource-defaults.ts index 402ee1fe..de1eaabd 100644 --- a/packages/shared/src/cli/commands/plugin/create/resource-defaults.ts +++ b/packages/shared/src/cli/commands/plugin/create/resource-defaults.ts @@ -98,6 +98,11 @@ export const DEFAULT_FIELDS_BY_TYPE: Record< }, }; +/** Valid resource type values from the schema. */ +export function getValidResourceTypes(): string[] { + return RESOURCE_TYPE_OPTIONS.map((o) => o.value); +} + /** Humanized alias from resource type (e.g. sql_warehouse -> "SQL Warehouse"). */ export function humanizeResourceType(type: string): string { const option = RESOURCE_TYPE_OPTIONS.find((o) => o.value === type); diff --git a/packages/shared/src/cli/commands/plugin/sync/sync.ts b/packages/shared/src/cli/commands/plugin/sync/sync.ts index 4b2c21f0..2cfaff4e 100644 --- a/packages/shared/src/cli/commands/plugin/sync/sync.ts +++ b/packages/shared/src/cli/commands/plugin/sync/sync.ts @@ -529,15 +529,14 @@ function writeManifest( plugins, }; + const serialized = JSON.stringify(templateManifest, null, 2); + if (options.json) { - console.log(JSON.stringify(templateManifest, null, 2)); + console.log(serialized); } if (options.write) { - fs.writeFileSync( - outputPath, - `${JSON.stringify(templateManifest, null, 2)}\n`, - ); + fs.writeFileSync(outputPath, `${serialized}\n`); if (!options.silent && !options.json) { console.log(`\n✓ Wrote ${outputPath}`); } @@ -546,7 +545,7 @@ function writeManifest( console.log(" npx appkit plugin sync --write\n"); console.log("Preview:"); console.log("─".repeat(60)); - console.log(JSON.stringify(templateManifest, null, 2)); + console.log(serialized); console.log("─".repeat(60)); } } @@ -684,9 +683,10 @@ async function runPluginsSync(options: { const pluginCount = Object.keys(plugins).length; if (pluginCount === 0) { - if (options.silent) { + if (options.silent || options.json) { writeManifest(outputPath, { plugins: {} }, options); - return; + if (options.silent) return; + process.exit(1); } console.log("No plugins found."); if (options.pluginsDir) { diff --git a/packages/shared/src/cli/commands/plugin/validate/validate.ts b/packages/shared/src/cli/commands/plugin/validate/validate.ts index 59ec7b7a..306ee7a8 100644 --- a/packages/shared/src/cli/commands/plugin/validate/validate.ts +++ b/packages/shared/src/cli/commands/plugin/validate/validate.ts @@ -128,10 +128,12 @@ async function runPluginValidate( console.log(`✓ ${relativePath}`); } } else { - const errors = result.errors?.length - ? formatValidationErrors(result.errors, obj).split("\n").filter(Boolean) - : []; if (options.json) { + const errors = result.errors?.length + ? formatValidationErrors(result.errors, obj) + .split("\n") + .filter(Boolean) + : []; jsonResults.push({ path: relativePath, valid: false, From 3657464230b0780191d32924de3c147d9feee619 Mon Sep 17 00:00:00 2001 From: MarioCadenas Date: Mon, 13 Apr 2026 15:32:56 +0200 Subject: [PATCH 6/6] docs: update plugin CLI docs with non-interactive usage examples Update plugin-management.md and custom-plugins.md to document both interactive and non-interactive modes for `plugin create` and `plugin add-resource`. Add flag-based examples and mention --help for discovering all options. Signed-off-by: MarioCadenas --- docs/docs/plugins/custom-plugins.md | 4 ++++ docs/docs/plugins/plugin-management.md | 28 ++++++++++++++++++++------ 2 files changed, 26 insertions(+), 6 deletions(-) diff --git a/docs/docs/plugins/custom-plugins.md b/docs/docs/plugins/custom-plugins.md index 8ccd58b4..7b7cf568 100644 --- a/docs/docs/plugins/custom-plugins.md +++ b/docs/docs/plugins/custom-plugins.md @@ -7,7 +7,11 @@ sidebar_position: 7 If you need custom API routes or background logic, implement an AppKit plugin. The fastest way is to use the CLI: ```bash +# Interactive npx @databricks/appkit plugin create + +# Non-interactive +npx @databricks/appkit plugin create --placement in-repo --path plugins/my-plugin --name my-plugin --description "My plugin" --force ``` For a deeper understanding of the plugin structure, read on. diff --git a/docs/docs/plugins/plugin-management.md b/docs/docs/plugins/plugin-management.md index 41be9ff6..6fb6c8df 100644 --- a/docs/docs/plugins/plugin-management.md +++ b/docs/docs/plugins/plugin-management.md @@ -10,17 +10,28 @@ AppKit includes a CLI for managing plugins. All commands are available under `np ## Create a plugin -Scaffold a new plugin interactively: +Scaffold a new plugin interactively or via flags: ```bash +# Interactive mode (prompts for all options) npx @databricks/appkit plugin create + +# Non-interactive mode (all required flags provided) +npx @databricks/appkit plugin create \ + --placement in-repo \ + --path plugins/my-plugin \ + --name my-plugin \ + --description "My custom plugin" \ + --resources sql_warehouse \ + --force ``` -The wizard walks you through: +In interactive mode, the wizard walks you through: - **Placement**: In your repository (e.g. `plugins/my-plugin`) or as a standalone package - **Metadata**: Name, display name, description - **Resources**: Which Databricks resources the plugin needs (SQL Warehouse, Secret, etc.) and whether each is required or optional -- **Optional fields**: Author, version, license + +In non-interactive mode, `--placement`, `--path`, `--name`, and `--description` are required. Resources can be specified as a comma-separated list (`--resources sql_warehouse,volume`) or as JSON for full control (`--resources-json '[{"type":"sql_warehouse","permission":"CAN_MANAGE"}]'`). For all available options, run `npx @databricks/appkit plugin create --help`. The command generates a complete plugin scaffold with `manifest.json` and a TypeScript plugin class that imports the manifest directly — ready to register in your app. @@ -88,11 +99,16 @@ npx @databricks/appkit plugin list --json ## Add a resource to a plugin -Interactively add a new resource requirement to an existing plugin manifest. **Requires `manifest.json`** in the plugin directory (the command edits it in place; it does not modify `manifest.js`): +Add a new resource requirement to an existing plugin manifest. **Requires `manifest.json`** in the plugin directory (the command edits it in place; it does not modify `manifest.js`): ```bash +# Interactive mode npx @databricks/appkit plugin add-resource - -# Or specify the plugin directory npx @databricks/appkit plugin add-resource --path plugins/my-plugin + +# Non-interactive mode (--type triggers flag-based mode) +npx @databricks/appkit plugin add-resource --path plugins/my-plugin --type sql_warehouse +npx @databricks/appkit plugin add-resource --path plugins/my-plugin --type volume --no-required --dry-run ``` + +In non-interactive mode, only `--type` is required — all other fields (permission, resource key, field env vars) default to sensible values from the schema. Use `--dry-run` to preview the updated manifest without writing. For all available options, run `npx @databricks/appkit plugin add-resource --help`.