From 1963ac0ea8a8aacd1dd047ebae2df1d9a032b9a3 Mon Sep 17 00:00:00 2001
From: Neil <4138956+nwparker@users.noreply.github.com>
Date: Fri, 30 Oct 2026 01:00:15 -0700
Subject: [PATCH] cli: add orca plugin create, validate and inspect (#36280)
* cli: add orca plugin create, validate and inspect
* build: ship orca plugin validate's main checks as a CLI main entry
---
.../build-plugins/plain-node-entry-guard.ts | 1 +
config/electron-builder.config.cjs | 1 +
.../electron-vite-output-contract.test.ts | 1 +
config/scripts/validate-plugin-files.mjs | 78 +-------
docs/reference/plugin-authoring.md | 6 +
src/cli/cli-error.ts | 10 +-
src/cli/cli-exit-code.test.ts | 20 ++
src/cli/cli-exit-code.ts | 13 ++
src/cli/handler-group-manifest.ts | 5 +
src/cli/handlers/plugin-authoring.ts | 135 +++++++++++++
src/cli/index.ts | 3 +-
src/cli/plugin-authoring/plugin-cli-output.ts | 37 ++++
.../plugin-content-templates.ts | 188 ++++++++++++++++++
src/cli/plugin-authoring/plugin-create.ts | 79 ++++++++
.../plugin-folder-validation.ts | 147 ++++++++++++++
.../plugin-frame-templates.ts | 135 +++++++++++++
.../plugin-authoring/plugin-template-files.ts | 54 +++++
.../plugin-authoring/plugin-template-names.ts | 16 ++
.../plugin-authoring/plugin-templates.test.ts | 75 +++++++
src/cli/plugin-authoring/plugin-templates.ts | 23 +++
src/cli/remote-selection-scope.ts | 5 +-
src/cli/root-help-text-primary.ts | 5 +
src/cli/specs/index.ts | 4 +-
src/cli/specs/plugin.ts | 56 ++++++
src/main/plugins/plugin-authoring-checks.ts | 7 +
src/main/startup/cli-command-names.ts | 1 +
.../plugin-strict-artifact-checks.ts | 126 ++++++++++++
27 files changed, 1154 insertions(+), 77 deletions(-)
create mode 100644 src/cli/cli-exit-code.test.ts
create mode 100644 src/cli/cli-exit-code.ts
create mode 100644 src/cli/handlers/plugin-authoring.ts
create mode 100644 src/cli/plugin-authoring/plugin-cli-output.ts
create mode 100644 src/cli/plugin-authoring/plugin-content-templates.ts
create mode 100644 src/cli/plugin-authoring/plugin-create.ts
create mode 100644 src/cli/plugin-authoring/plugin-folder-validation.ts
create mode 100644 src/cli/plugin-authoring/plugin-frame-templates.ts
create mode 100644 src/cli/plugin-authoring/plugin-template-files.ts
create mode 100644 src/cli/plugin-authoring/plugin-template-names.ts
create mode 100644 src/cli/plugin-authoring/plugin-templates.test.ts
create mode 100644 src/cli/plugin-authoring/plugin-templates.ts
create mode 100644 src/cli/specs/plugin.ts
create mode 100644 src/main/plugins/plugin-authoring-checks.ts
create mode 100644 src/shared/plugins/authoring/plugin-strict-artifact-checks.ts
diff --git a/config/build-plugins/plain-node-entry-guard.ts b/config/build-plugins/plain-node-entry-guard.ts
index 4909e4bf070..6537cfc8901 100644
--- a/config/build-plugins/plain-node-entry-guard.ts
+++ b/config/build-plugins/plain-node-entry-guard.ts
@@ -20,6 +20,7 @@ export const CLI_MAIN_ENTRY_NAMES = [
'skills/bundled-skill-install/cli-entry',
'macos-tree-clone',
'orcad/orcad-stale-lock-reclaim',
+ 'plugins/plugin-authoring-checks',
...[
'access',
'active-location',
diff --git a/config/electron-builder.config.cjs b/config/electron-builder.config.cjs
index d8e788e1b90..e171d4b41e6 100644
--- a/config/electron-builder.config.cjs
+++ b/config/electron-builder.config.cjs
@@ -334,6 +334,7 @@ module.exports = {
'out/main/macos-tree-clone.js',
'out/main/orca-profiles/profile-index-store.js',
'out/main/persistence/profile-state/**',
+ 'out/main/plugins/plugin-authoring-checks.js',
'out/main/skills/bundled-skill-install/cli-entry.js',
'out/main/startup/http1-compatibility-marker.js',
'out/main/daemon-entry.js',
diff --git a/config/scripts/electron-vite-output-contract.test.ts b/config/scripts/electron-vite-output-contract.test.ts
index 2b69f00bf8b..27bdd56e1c5 100644
--- a/config/scripts/electron-vite-output-contract.test.ts
+++ b/config/scripts/electron-vite-output-contract.test.ts
@@ -197,6 +197,7 @@ describe('Electron Vite output contract', () => {
'persistence/profile-state/legacy-json/profile-state-recovery',
'persistence/profile-state/profile-state-recovery-command',
'persistence/profile-state/profile-state-storage-classification',
+ 'plugins/plugin-authoring-checks',
'skills/bundled-skill-install/cli-entry',
'startup/http1-compatibility-marker'
]) {
diff --git a/config/scripts/validate-plugin-files.mjs b/config/scripts/validate-plugin-files.mjs
index 46bb0566d0d..47fe6b10672 100644
--- a/config/scripts/validate-plugin-files.mjs
+++ b/config/scripts/validate-plugin-files.mjs
@@ -20,77 +20,21 @@ const { materializePluginGitTree } = loadPluginSchemaModule(
)
const { hashPluginTree } = loadPluginSchemaModule('../../main/plugins/plugin-content-hash.ts')
const { gitExecFileAsync } = loadPluginSchemaModule('../../main/git/runner.ts')
-const { formatPluginSchemaIssues } = loadPluginSchemaModule('manifest/plugin-schema-issues.ts')
const { isPluginRepositoryPath } = loadPluginSchemaModule('installation/plugin-path-safety.ts')
const { parseGitRepositoryIdentity } = loadPluginSchemaModule('catalog/plugin-marketplace.ts')
const { catalogImagesFromManifest } = loadPluginSchemaModule(
'images/plugin-manifest-catalog-images.ts'
)
-const ARTIFACT_PARSERS = {
- themes: loadPluginSchemaModule('artifacts/plugin-theme-artifact.ts').parsePluginAppThemeArtifact,
- terminalThemes: loadPluginSchemaModule('artifacts/plugin-terminal-theme-artifact.ts')
- .parsePluginTerminalThemeArtifact,
- iconThemes: loadPluginSchemaModule('artifacts/plugin-icon-theme-artifact.ts')
- .parsePluginIconThemeArtifact
-}
-const { parsePluginLanguagePackArtifact } = loadPluginSchemaModule(
- 'artifacts/plugin-language-pack-artifact.ts'
-)
-const { parsePluginLanguageConfiguration } = loadPluginSchemaModule(
- 'artifacts/plugin-syntax-artifact.ts'
+// Why shared: `orca plugin validate` runs these same strict artifact checks.
+const { strictPluginArtifactErrors } = loadPluginSchemaModule(
+ 'authoring/plugin-strict-artifact-checks.ts'
)
-// Why strict here: Orca skips unknown theme tokens and icon slots at runtime, so authors only
-// learn about a typo from this check.
-function artifactErrors(pluginDir, manifest) {
- return Object.entries(ARTIFACT_PARSERS).flatMap(([point, parse]) =>
- manifest.contributes[point].flatMap((contribution) => {
- let text = ''
- try {
- text = readFileSync(path.join(pluginDir, ...contribution.path.split('/')), 'utf8')
- parse(text, 'strict')
- return []
- } catch (error) {
- const detail = error.issues
- ? formatPluginSchemaIssues(error.issues, JSON.parse(text))
- : error.message
- return [`contributes.${point} "${contribution.id}" (${contribution.path}): ${detail}`]
- }
- })
- )
-}
-
-// Why: Orca drops protected security copy from a pack at load, which authors would never notice.
-function languagePackErrors(pluginDir, manifest) {
- return manifest.contributes.languagePacks.flatMap((pack) => {
- const file = path.join(pluginDir, ...pack.path.split('/'))
- const parsed = existsSync(file)
- ? parsePluginLanguagePackArtifact(readFileSync(file, 'utf8'), 'strict')
- : { ok: false, error: 'file not found' }
- return parsed.ok
- ? []
- : [`contributes.languagePacks "${pack.locale}" (${pack.path}): ${parsed.error}`]
- })
-}
-
-// Why: Orca drops an unsafe auto-closing pair or comment marker at load instead of failing.
-function syntaxConfigurationErrors(pluginDir, manifest) {
- return manifest.contributes.syntaxes.flatMap((syntax) => {
- if (!syntax.configuration) {
- return []
- }
- let text = ''
- try {
- text = readFileSync(path.join(pluginDir, ...syntax.configuration.split('/')), 'utf8')
- parsePluginLanguageConfiguration(text, 'strict')
- return []
- } catch (error) {
- const detail = error.issues
- ? formatPluginSchemaIssues(error.issues, JSON.parse(text))
- : error.message
- return [`contributes.syntaxes "${syntax.id}" (${syntax.configuration}): ${detail}`]
- }
- })
+function readPluginText(pluginDir) {
+ return (manifestPath) => {
+ const file = path.join(pluginDir, ...manifestPath.split('/'))
+ return existsSync(file) ? readFileSync(file, 'utf8') : null
+ }
}
async function git(cwd, args) {
@@ -206,11 +150,7 @@ for (const target of targets) {
const errors = validatePluginFile(path.basename(file), raw, readCatalogFile)
if (errors.length === 0 && path.basename(file) === 'orca-plugin.json') {
const { manifest } = parsePluginManifest(raw)
- errors.push(
- ...artifactErrors(root, manifest),
- ...languagePackErrors(root, manifest),
- ...syntaxConfigurationErrors(root, manifest)
- )
+ errors.push(...strictPluginArtifactErrors(manifest, readPluginText(root)))
}
for (const error of errors) {
console.error(`${file}: ${error}`)
diff --git a/docs/reference/plugin-authoring.md b/docs/reference/plugin-authoring.md
index 5a0c350622d..837504a23d9 100644
--- a/docs/reference/plugin-authoring.md
+++ b/docs/reference/plugin-authoring.md
@@ -155,8 +155,14 @@ worker, and the plugin's logs to see what it printed.
```sh
pnpm validate:plugin path/to/plugin
+# or, without an Orca checkout:
+orca plugin validate path/to/plugin
```
+`orca plugin create
--template `
+scaffolds a plugin that passes these checks, and `orca plugin inspect ` shows what it would be
+allowed to do and its content hash.
+
This runs the manifest checks Orca runs, strictly: unknown keys, limits, contributions beyond the
execution profile, and an `engines.orca` too low for the fields used. On a catalog it also
checks that listed images exist and fit. It warns when the manifest still declares the deprecated
diff --git a/src/cli/cli-error.ts b/src/cli/cli-error.ts
index cbd37f3e9fa..7656ddd4078 100644
--- a/src/cli/cli-error.ts
+++ b/src/cli/cli-error.ts
@@ -56,7 +56,7 @@ function errorData(error: unknown): unknown {
return error instanceof RuntimeClientError ? error.data : undefined
}
-function errorCode(error: unknown): string | undefined {
+export function cliErrorCode(error: unknown): string | undefined {
if (error instanceof RuntimeRpcFailureError) {
return error.response.error.code
}
@@ -65,7 +65,7 @@ function errorCode(error: unknown): string | undefined {
export function formatCliError(error: unknown, context: CliErrorContext = {}): string {
const message = error instanceof Error ? error.message : String(error)
- const selector = contextRecovery(errorCode(error), context)
+ const selector = contextRecovery(cliErrorCode(error), context)
if (selector) {
return formatMessageWithNextSteps(
message,
@@ -128,7 +128,7 @@ function formatRuntimeUnavailableError(message: string, data: unknown): string {
}
export function reportCliError(error: unknown, json: boolean, context: CliErrorContext = {}): void {
- const selector = contextRecovery(errorCode(error), context)
+ const selector = contextRecovery(cliErrorCode(error), context)
if (json) {
if (error instanceof RuntimeRpcFailureError) {
const sidecarExit = computerSidecarExitRecovery(error, context)
@@ -246,7 +246,7 @@ function nextStepsFromData(data: unknown): string[] {
function computerSidecarExitRecovery(error: unknown, context: CliErrorContext) {
if (
context.commandPath?.[0] !== 'computer' ||
- errorCode(error) !== 'accessibility_error' ||
+ cliErrorCode(error) !== 'accessibility_error' ||
!(error instanceof Error) ||
!error.message.startsWith('computer sidecar exited with code')
) {
@@ -260,7 +260,7 @@ function localCliErrorData(error: unknown, context: CliErrorContext): unknown {
if (sidecarExit) {
return sidecarExit
}
- const selector = contextRecovery(errorCode(error), context)
+ const selector = contextRecovery(cliErrorCode(error), context)
// Error-specific recovery takes precedence over the generic fallback.
if (error instanceof RuntimeClientError && error.data !== undefined) {
return mergeContextRecovery(error.data, selector)
diff --git a/src/cli/cli-exit-code.test.ts b/src/cli/cli-exit-code.test.ts
new file mode 100644
index 00000000000..48fab1ed31a
--- /dev/null
+++ b/src/cli/cli-exit-code.test.ts
@@ -0,0 +1,20 @@
+import { describe, expect, it } from 'vitest'
+import { cliErrorExitCode } from './cli-exit-code'
+import { RuntimeClientError } from './runtime/types'
+
+describe('cliErrorExitCode', () => {
+ it('gives plugin commands distinct exit codes for invalid plugins and pending approval', () => {
+ const plugin = ['plugin', 'dev', 'load']
+ expect(cliErrorExitCode(new RuntimeClientError('plugin_invalid', 'x'), plugin)).toBe(2)
+ expect(cliErrorExitCode(new RuntimeClientError('approval_required', 'x'), plugin)).toBe(3)
+ expect(cliErrorExitCode(new RuntimeClientError('approval_stale', 'x'), plugin)).toBe(3)
+ expect(cliErrorExitCode(new RuntimeClientError('runtime_unavailable', 'x'), plugin)).toBe(1)
+ expect(cliErrorExitCode(new Error('boom'), plugin)).toBe(1)
+ })
+
+ it('keeps every other command on exit code 1', () => {
+ expect(
+ cliErrorExitCode(new RuntimeClientError('approval_required', 'x'), ['worktree', 'list'])
+ ).toBe(1)
+ })
+})
diff --git a/src/cli/cli-exit-code.ts b/src/cli/cli-exit-code.ts
new file mode 100644
index 00000000000..7c3a86e7007
--- /dev/null
+++ b/src/cli/cli-exit-code.ts
@@ -0,0 +1,13 @@
+import { cliErrorCode } from './cli-error'
+
+const PLUGIN_EXIT_CODES: Readonly> = {
+ plugin_invalid: 2,
+ approval_required: 3,
+ approval_stale: 3
+}
+
+/** `orca plugin` exit codes are a stable contract so scripts can tell "invalid" and "ask a human" apart from failure. */
+export function cliErrorExitCode(error: unknown, commandPath: readonly string[]): number {
+ const code = cliErrorCode(error)
+ return commandPath[0] === 'plugin' && code ? (PLUGIN_EXIT_CODES[code] ?? 1) : 1
+}
diff --git a/src/cli/handler-group-manifest.ts b/src/cli/handler-group-manifest.ts
index 2e02cbee829..61ccd315603 100644
--- a/src/cli/handler-group-manifest.ts
+++ b/src/cli/handler-group-manifest.ts
@@ -278,6 +278,11 @@ export const HANDLER_GROUPS: readonly HandlerGroup[] = [
keys: ['skills installed', 'skills share'],
load: async () => (await import('./handlers/skill-sharing.js')).SKILL_SHARING_HANDLERS
},
+ {
+ name: 'plugin-authoring',
+ keys: ['plugin create', 'plugin validate', 'plugin inspect'],
+ load: async () => (await import('./handlers/plugin-authoring.js')).PLUGIN_AUTHORING_HANDLERS
+ },
{
name: 'skills',
keys: ['skills list', 'skills get', 'skills install', 'skills update'],
diff --git a/src/cli/handlers/plugin-authoring.ts b/src/cli/handlers/plugin-authoring.ts
new file mode 100644
index 00000000000..dc6f47a8c33
--- /dev/null
+++ b/src/cli/handlers/plugin-authoring.ts
@@ -0,0 +1,135 @@
+import type { CommandHandler } from '../dispatch'
+import { createPluginFromTemplate, PluginCreateError } from '../plugin-authoring/plugin-create'
+import {
+ inspectPluginFolder,
+ PLUGIN_INVALID_CODE,
+ validatePluginTarget,
+ type PluginInspection,
+ type PluginValidationReport
+} from '../plugin-authoring/plugin-folder-validation'
+import {
+ isPluginTemplateName,
+ PLUGIN_TEMPLATE_NAMES
+} from '../plugin-authoring/plugin-template-names'
+import {
+ printPluginResult,
+ requirePluginPathArg,
+ resolveLocalPluginPath
+} from '../plugin-authoring/plugin-cli-output'
+import { RuntimeClientError } from '../runtime/types'
+
+function optionalString(flags: Map, name: string): string | undefined {
+ const value = flags.get(name)
+ return typeof value === 'string' && value.trim() ? value.trim() : undefined
+}
+
+function formatGrants(report: PluginValidationReport): string[] {
+ const plugin = report.plugin
+ if (!plugin) {
+ return []
+ }
+ return [
+ `${plugin.pluginKey} ${plugin.version} (${plugin.grants.execution})`,
+ ` ${plugin.grants.executionDescription}`,
+ ...plugin.grants.capabilities.map((capability) => ` - ${capability.description}`),
+ ...plugin.grants.contributions.map((item) => ` adds ${item.kind}: ${item.name}`),
+ plugin.needsApproval
+ ? ' Loading or enabling it needs --approve ; Orca prints the fingerprint.'
+ : ' Content only: loads without approval.'
+ ]
+}
+
+function invalidError(report: PluginValidationReport): RuntimeClientError {
+ return new RuntimeClientError(
+ PLUGIN_INVALID_CODE,
+ [
+ `${report.file} is not a valid plugin file:`,
+ ...report.errors.map((error) => `- ${error}`)
+ ].join('\n'),
+ {
+ ...report,
+ nextSteps: ['Fix the listed problems, then run `orca plugin validate` again.']
+ }
+ )
+}
+
+async function readTarget(
+ read: (path: string) => Promise,
+ path: string
+): Promise {
+ try {
+ return await read(path)
+ } catch (error) {
+ throw new RuntimeClientError(
+ 'invalid_argument',
+ error instanceof Error ? error.message : String(error)
+ )
+ }
+}
+
+function formatInspection(report: PluginInspection): string {
+ return [
+ ...formatGrants(report),
+ ...(report.contentHash
+ ? [
+ `Content hash: ${report.contentHash} (${report.fileCount} files, ${report.totalBytes} bytes)`
+ ]
+ : [])
+ ].join('\n')
+}
+
+export const PLUGIN_AUTHORING_HANDLERS: Record = {
+ 'plugin create': async (ctx) => {
+ const dir = resolveLocalPluginPath(ctx, requirePluginPathArg(ctx, 'dir'), 'create')
+ const template = optionalString(ctx.flags, 'template')
+ if (!template || !isPluginTemplateName(template)) {
+ throw new RuntimeClientError(
+ 'invalid_argument',
+ `--template must be one of: ${PLUGIN_TEMPLATE_NAMES.join(', ')}`,
+ { templates: [...PLUGIN_TEMPLATE_NAMES] }
+ )
+ }
+ let created
+ try {
+ created = await createPluginFromTemplate({
+ dir,
+ template,
+ publisher: optionalString(ctx.flags, 'publisher'),
+ id: optionalString(ctx.flags, 'id'),
+ name: optionalString(ctx.flags, 'name')
+ })
+ } catch (error) {
+ if (error instanceof PluginCreateError) {
+ throw new RuntimeClientError('invalid_argument', error.message)
+ }
+ throw error
+ }
+ const report = await validatePluginTarget(created.path)
+ printPluginResult(
+ ctx,
+ { ...created, validation: report },
+ [
+ `Created ${created.pluginKey} from the ${template} template in ${created.path}`,
+ ...created.files.map((file) => ` ${file}`),
+ ...formatGrants(report),
+ 'Next: orca plugin dev load '
+ ].join('\n')
+ )
+ },
+ 'plugin validate': async (ctx) => {
+ const path = resolveLocalPluginPath(ctx, requirePluginPathArg(ctx, 'path'), 'validate')
+ const report = await readTarget(validatePluginTarget, path)
+ if (!report.valid) {
+ throw invalidError(report)
+ }
+ printPluginResult(ctx, report, [`${report.file}: ok`, ...formatGrants(report)].join('\n'))
+ },
+ 'plugin inspect': async (ctx) => {
+ const path = resolveLocalPluginPath(ctx, requirePluginPathArg(ctx, 'path'), 'inspect')
+ const report = await readTarget(inspectPluginFolder, path)
+ if (!report.valid) {
+ throw invalidError(report)
+ }
+ printPluginResult(ctx, report, formatInspection(report))
+ }
+}
diff --git a/src/cli/index.ts b/src/cli/index.ts
index 8986d01212f..5c4789bcd23 100644
--- a/src/cli/index.ts
+++ b/src/cli/index.ts
@@ -16,6 +16,7 @@ import {
} from './execution-host-flag'
import { fetchSshTargetSummaries, listSshTargets } from './host-selector-alternatives'
import { reportCliError } from './cli-error'
+import { cliErrorExitCode } from './cli-exit-code'
import { printHelp } from './help'
import type { RuntimeClient } from './runtime-client'
import { COMMAND_SPECS } from './specs'
@@ -171,7 +172,7 @@ export async function main(
(typeof hostFlag === 'string' && hostFlag.startsWith('runtime:')) ||
Boolean(process.env.ORCA_ENVIRONMENT?.trim() || process.env.ORCA_PAIRING_CODE?.trim())
})
- process.exitCode = 1
+ process.exitCode = cliErrorExitCode(error, parsed.commandPath)
}
}
diff --git a/src/cli/plugin-authoring/plugin-cli-output.ts b/src/cli/plugin-authoring/plugin-cli-output.ts
new file mode 100644
index 00000000000..1fdf64eedb9
--- /dev/null
+++ b/src/cli/plugin-authoring/plugin-cli-output.ts
@@ -0,0 +1,37 @@
+import { isAbsolute, resolve } from 'node:path'
+import type { HandlerContext } from '../dispatch'
+import { rejectRemoteSelectionFlags } from '../remote-selection-flag-rejection'
+import { RuntimeClientError } from '../runtime/types'
+import { writeStdoutLine } from '../stdout-line'
+
+/** One `{ ok, result }` object on stdout for --json; plain text otherwise. */
+export function printPluginResult(ctx: HandlerContext, result: unknown, text: string): void {
+ writeStdoutLine(ctx.json ? JSON.stringify({ ok: true, result }, null, 2) : text)
+}
+
+export function requirePluginPathArg(ctx: HandlerContext, flag: string): string {
+ const value = ctx.flags.get(flag)
+ if (typeof value !== 'string' || value.trim() === '') {
+ throw new RuntimeClientError('invalid_argument', `Missing <${flag}>.`)
+ }
+ return value
+}
+
+/**
+ * Authoring commands read and write the files of the machine that runs them. Why refuse a bridged
+ * shell: there the CLI runs on the Orca host, so the caller's paths would name the wrong machine.
+ */
+export function resolveLocalPluginPath(
+ ctx: HandlerContext,
+ value: string,
+ command: string
+): string {
+ rejectRemoteSelectionFlags(ctx.flags, `orca plugin ${command}; it works on local files`)
+ if (process.env.ORCA_CLI_CWD) {
+ throw new RuntimeClientError(
+ 'invalid_environment',
+ `orca plugin ${command} works on the files of the machine that runs it, but this shell forwards orca to the Orca host. Write and check the plugin files on this machine directly, or run the command on the Orca host.`
+ )
+ }
+ return isAbsolute(value) ? value : resolve(ctx.cwd, value)
+}
diff --git a/src/cli/plugin-authoring/plugin-content-templates.ts b/src/cli/plugin-authoring/plugin-content-templates.ts
new file mode 100644
index 00000000000..da962b05060
--- /dev/null
+++ b/src/cli/plugin-authoring/plugin-content-templates.ts
@@ -0,0 +1,188 @@
+import {
+ COMMON_FILES,
+ json,
+ manifest,
+ readme,
+ type PluginTemplate,
+ type PluginTemplateInput
+} from './plugin-template-files'
+
+// Content-only templates: execution "data", so loading them never needs approval.
+
+export function theme(input: PluginTemplateInput): PluginTemplate {
+ const description = 'A dark app theme.'
+ return {
+ description: 'App color theme (data only, no approval needed)',
+ files: {
+ 'orca-plugin.json': manifest(input, description, {
+ execution: 'data',
+ contributes: {
+ themes: [{ id: `${input.id}-dark`, title: input.name, path: 'themes/dark.json' }]
+ }
+ }),
+ 'themes/dark.json': json({
+ schemaVersion: 1,
+ base: 'dark',
+ tokens: {
+ '--background': '#1b1430',
+ '--foreground': '#e8e0ff',
+ '--primary': '#b48cff',
+ '--primary-foreground': '#1b1430'
+ },
+ tokenColors: [
+ { scope: 'comment', settings: { foreground: '#8f84ad', fontStyle: 'italic' } },
+ { scope: ['keyword', 'storage'], settings: { foreground: '#ff7ad9' } },
+ { scope: 'string', settings: { foreground: '#a8e6a1' } }
+ ]
+ }),
+ 'README.md': readme(
+ input,
+ 'An Orca app theme. Edit `themes/dark.json`; omitted tokens are derived from `--background` and `--foreground`.'
+ ),
+ ...COMMON_FILES
+ }
+ }
+}
+
+export function terminalTheme(input: PluginTemplateInput): PluginTemplate {
+ return {
+ description: 'Terminal ANSI palette (data only, no approval needed)',
+ files: {
+ 'orca-plugin.json': manifest(input, 'A dark terminal palette.', {
+ execution: 'data',
+ contributes: {
+ terminalThemes: [{ id: input.id, title: input.name, path: 'terminal/dark.json' }]
+ }
+ }),
+ 'terminal/dark.json': json({
+ schemaVersion: 1,
+ mode: 'dark',
+ terminal: {
+ background: '#1b1430',
+ foreground: '#e8e0ff',
+ black: '#2a2142',
+ red: '#ff6b8b',
+ green: '#a8e6a1',
+ yellow: '#ffd479',
+ blue: '#82cfff',
+ magenta: '#ff7ad9',
+ cyan: '#7fe7e0',
+ white: '#e8e0ff'
+ }
+ }),
+ 'README.md': readme(input, 'An Orca terminal palette. Edit `terminal/dark.json`.'),
+ ...COMMON_FILES
+ }
+ }
+}
+
+const FILE_ICON = `\n`
+const FOLDER_ICON = `\n`
+const MARKDOWN_ICON = `\n`
+
+export function iconTheme(input: PluginTemplateInput): PluginTemplate {
+ return {
+ description: 'File and folder icon theme (data only, no approval needed)',
+ files: {
+ 'orca-plugin.json': manifest(input, 'File and folder icons.', {
+ execution: 'data',
+ contributes: {
+ iconThemes: [{ id: input.id, title: input.name, path: 'icons/theme.json' }]
+ }
+ }),
+ 'icons/theme.json': json({
+ schemaVersion: 1,
+ icons: {
+ file: 'icons/file.svg',
+ folder: 'icons/folder.svg',
+ 'folder-open': 'icons/folder.svg'
+ },
+ fileNames: { 'readme.md': 'icons/markdown.svg' },
+ fileExtensions: { md: 'icons/markdown.svg' }
+ }),
+ 'icons/file.svg': FILE_ICON,
+ 'icons/folder.svg': FOLDER_ICON,
+ 'icons/markdown.svg': MARKDOWN_ICON,
+ 'README.md': readme(
+ input,
+ 'An Orca icon theme. `icons/theme.json` maps slots, file names and extensions to SVG files (paths from the plugin root).'
+ ),
+ ...COMMON_FILES
+ }
+ }
+}
+
+export function syntax(input: PluginTemplateInput): PluginTemplate {
+ const scopeName = `source.${input.id.replace(/[^a-z0-9]/g, '')}`
+ const extension = `.${input.id.replace(/[^a-z0-9]/g, '').slice(0, 12) || 'example'}`
+ return {
+ description: 'TextMate syntax highlighting for a file type (data only, no approval needed)',
+ files: {
+ 'orca-plugin.json': manifest(input, `Syntax highlighting for ${extension} files.`, {
+ execution: 'data',
+ contributes: {
+ syntaxes: [
+ {
+ id: input.id,
+ title: input.name,
+ scopeName,
+ path: 'syntaxes/grammar.tmLanguage.json',
+ configuration: 'syntaxes/language-configuration.json',
+ extensions: [extension]
+ }
+ ]
+ }
+ }),
+ 'syntaxes/grammar.tmLanguage.json': json({
+ scopeName,
+ patterns: [{ include: '#comments' }, { include: '#strings' }, { include: '#keywords' }],
+ repository: {
+ comments: { patterns: [{ name: 'comment.line.number-sign', match: '#.*$' }] },
+ strings: { patterns: [{ name: 'string.quoted.double', begin: '"', end: '"' }] },
+ keywords: {
+ patterns: [{ name: 'keyword.control', match: '\\b(if|else|while|return)\\b' }]
+ }
+ }
+ }),
+ 'syntaxes/language-configuration.json': json({
+ comments: { lineComment: '#' },
+ brackets: [
+ ['{', '}'],
+ ['(', ')']
+ ],
+ autoClosingPairs: [
+ { open: '{', close: '}' },
+ { open: '(', close: ')' },
+ { open: '"', close: '"' }
+ ]
+ }),
+ 'README.md': readme(
+ input,
+ `Syntax highlighting for \`${extension}\` files. Edit the grammar in \`syntaxes/\`.`
+ ),
+ ...COMMON_FILES
+ }
+ }
+}
+
+export function languagePack(input: PluginTemplateInput): PluginTemplate {
+ return {
+ description: 'Interface translation (data only, no approval needed)',
+ files: {
+ 'orca-plugin.json': manifest(input, 'Translates Orca into Brazilian Portuguese.', {
+ execution: 'data',
+ contributes: {
+ languagePacks: [
+ { locale: 'pt-BR', path: 'locales/pt-BR.json', displayName: 'Português (Brasil)' }
+ ]
+ }
+ }),
+ 'locales/pt-BR.json': json({ common: { cancel: 'Cancelar', retry: 'Tentar novamente' } }),
+ 'README.md': readme(
+ input,
+ 'An Orca language pack. Keys mirror the English catalog of Orca; add translated strings to `locales/pt-BR.json`, or change the locale in `orca-plugin.json`.'
+ ),
+ ...COMMON_FILES
+ }
+ }
+}
diff --git a/src/cli/plugin-authoring/plugin-create.ts b/src/cli/plugin-authoring/plugin-create.ts
new file mode 100644
index 00000000000..bd7fd929f90
--- /dev/null
+++ b/src/cli/plugin-authoring/plugin-create.ts
@@ -0,0 +1,79 @@
+import { existsSync, readdirSync, statSync } from 'node:fs'
+import { mkdir, writeFile } from 'node:fs/promises'
+import { basename, dirname, join, resolve } from 'node:path'
+import { parsePluginManifest } from '../../shared/plugins/manifest/plugin-manifest'
+import { PLUGIN_TEMPLATE_NAMES, type PluginTemplateName } from './plugin-template-names'
+import { renderPluginTemplate } from './plugin-templates'
+
+export type PluginCreateResult = {
+ path: string
+ template: PluginTemplateName
+ pluginKey: string
+ files: string[]
+}
+
+export class PluginCreateError extends Error {}
+
+/** A manifest id from a folder name: lowercase words joined by `-`. */
+export function pluginIdFromFolder(folder: string): string {
+ return basename(folder)
+ .toLowerCase()
+ .replace(/[^a-z0-9]+/g, '-')
+ .replace(/^-+|-+$/g, '')
+}
+
+function titleFromId(id: string): string {
+ return id
+ .split('-')
+ .filter(Boolean)
+ .map((word) => word[0].toUpperCase() + word.slice(1))
+ .join(' ')
+}
+
+function assertEmptyFolder(dir: string): void {
+ if (!existsSync(dir)) {
+ return
+ }
+ if (!statSync(dir).isDirectory()) {
+ throw new PluginCreateError(`${dir} exists and is not a folder`)
+ }
+ // Why .git allowed: authors often `git init` first; nothing else is overwritten.
+ const entries = readdirSync(dir).filter((entry) => entry !== '.git')
+ if (entries.length > 0) {
+ throw new PluginCreateError(`${dir} is not empty; choose a new or empty folder`)
+ }
+}
+
+/** Writes a template into an empty folder. Never runs Git, installs dependencies or executes code. */
+export async function createPluginFromTemplate(input: {
+ dir: string
+ template: PluginTemplateName
+ publisher?: string
+ id?: string
+ name?: string
+}): Promise {
+ const dir = resolve(input.dir)
+ const id = input.id ?? pluginIdFromFolder(dir)
+ const publisher = input.publisher ?? 'your-github-user'
+ const name = input.name ?? (titleFromId(id) || id)
+ const template = renderPluginTemplate(input.template, { publisher, id, name })
+ const manifest = parsePluginManifest(JSON.parse(template.files['orca-plugin.json']))
+ if (!manifest.ok) {
+ throw new PluginCreateError(
+ `cannot create ${publisher}.${id}: ${manifest.error}. Pass --id and --publisher using lowercase letters, numbers and dashes.`
+ )
+ }
+ assertEmptyFolder(dir)
+ const files = Object.keys(template.files).sort()
+ for (const file of files) {
+ const target = join(dir, ...file.split('/'))
+ await mkdir(dirname(target), { recursive: true })
+ await writeFile(target, template.files[file], { flag: 'wx' })
+ }
+ return { path: dir, template: input.template, pluginKey: `${publisher}.${id}`, files }
+}
+
+export const PLUGIN_TEMPLATE_LIST = PLUGIN_TEMPLATE_NAMES.map((name) => ({
+ name,
+ description: renderPluginTemplate(name, { publisher: 'p', id: 'x', name: 'X' }).description
+}))
diff --git a/src/cli/plugin-authoring/plugin-folder-validation.ts b/src/cli/plugin-authoring/plugin-folder-validation.ts
new file mode 100644
index 00000000000..b58f57c572b
--- /dev/null
+++ b/src/cli/plugin-authoring/plugin-folder-validation.ts
@@ -0,0 +1,147 @@
+import { createHash } from 'node:crypto'
+import { existsSync, readFileSync, statSync } from 'node:fs'
+import { basename, dirname, join, resolve, sep } from 'node:path'
+import {
+ hashPluginTree,
+ validateDeclaredPluginArtifacts,
+ validatePluginInstallContent
+} from '../../main/plugins/plugin-authoring-checks'
+import { PLUGIN_CATALOG_FILENAME } from '../../shared/plugins/catalog/plugin-catalog'
+import {
+ pluginApprovalGrants,
+ pluginNeedsCliApproval,
+ type PluginApprovalGrants
+} from '../../shared/plugins/approval/plugin-cli-approval'
+import { strictPluginArtifactErrors } from '../../shared/plugins/authoring/plugin-strict-artifact-checks'
+import { validatePluginFile } from '../../shared/plugins/installation/plugin-file-validation'
+import {
+ PLUGIN_MANIFEST_FILENAME,
+ parsePluginManifest,
+ qualifiedPluginKey,
+ type PluginManifest
+} from '../../shared/plugins/manifest/plugin-manifest'
+
+export const PLUGIN_INVALID_CODE = 'plugin_invalid'
+
+export type PluginFolderSummary = {
+ pluginKey: string
+ name: string
+ version: string
+ /** Whether loading or enabling it needs `--approve`; content-only plugins never do. */
+ needsApproval: boolean
+ grants: PluginApprovalGrants
+}
+
+export type PluginValidationReport = {
+ path: string
+ file: string
+ kind: 'plugin' | 'catalog'
+ valid: boolean
+ errors: string[]
+ plugin?: PluginFolderSummary
+}
+
+function manifestTextReader(root: string) {
+ return (manifestPath: string): string | null => {
+ const file = join(root, ...manifestPath.split('/'))
+ return existsSync(file) && statSync(file).isFile() ? readFileSync(file, 'utf8') : null
+ }
+}
+
+// Why contained: a catalog image path must stay inside the catalog repository.
+function catalogFileReader(root: string) {
+ return (relative: string) => {
+ const target = resolve(root, relative)
+ if (!target.startsWith(`${resolve(root)}${sep}`) || !existsSync(target)) {
+ return null
+ }
+ const bytes = readFileSync(target)
+ return { bytes, sha256: createHash('sha256').update(bytes).digest('hex') }
+ }
+}
+
+function resolveTargetFile(target: string): string {
+ if (!existsSync(target)) {
+ throw new Error(`${target} does not exist`)
+ }
+ if (!statSync(target).isDirectory()) {
+ return target
+ }
+ const found = [PLUGIN_MANIFEST_FILENAME, PLUGIN_CATALOG_FILENAME]
+ .map((name) => join(target, name))
+ .find((file) => existsSync(file))
+ if (!found) {
+ throw new Error(`${target} has no ${PLUGIN_MANIFEST_FILENAME} or ${PLUGIN_CATALOG_FILENAME}`)
+ }
+ return found
+}
+
+async function contentErrors(root: string, manifest: PluginManifest): Promise {
+ const errors = strictPluginArtifactErrors(manifest, manifestTextReader(root))
+ for (const check of [validateDeclaredPluginArtifacts, validatePluginInstallContent]) {
+ const result = await check(root, manifest)
+ if (!result.ok) {
+ errors.push(result.error)
+ }
+ }
+ return errors
+}
+
+/** The checks `pnpm validate:plugin` runs, plus every file Orca reads at load. Offline; never runs plugin code. */
+export async function validatePluginTarget(target: string): Promise {
+ const file = resolveTargetFile(target)
+ const root = dirname(file)
+ const name = basename(file)
+ const kind = name === PLUGIN_CATALOG_FILENAME ? 'catalog' : 'plugin'
+ let raw: unknown
+ try {
+ raw = JSON.parse(readFileSync(file, 'utf8'))
+ } catch (error) {
+ const detail = error instanceof Error ? error.message : String(error)
+ return { path: root, file, kind, valid: false, errors: [`invalid JSON: ${detail}`] }
+ }
+ const errors = validatePluginFile(name, raw, catalogFileReader(root))
+ const parsed = kind === 'plugin' && errors.length === 0 ? parsePluginManifest(raw) : null
+ if (!parsed?.ok) {
+ return { path: root, file, kind, valid: errors.length === 0, errors }
+ }
+ const { manifest } = parsed
+ errors.push(...(await contentErrors(root, manifest)))
+ return {
+ path: root,
+ file,
+ kind,
+ valid: errors.length === 0,
+ errors,
+ plugin: {
+ pluginKey: qualifiedPluginKey(manifest),
+ name: manifest.name,
+ version: manifest.version,
+ needsApproval: pluginNeedsCliApproval(manifest),
+ grants: pluginApprovalGrants(manifest)
+ }
+ }
+}
+
+export type PluginInspection = PluginValidationReport & {
+ /** What `orca plugin install` of these exact files would record; absent when unhashable. */
+ contentHash?: string
+ fileCount?: number
+ totalBytes?: number
+}
+
+export async function inspectPluginFolder(target: string): Promise {
+ const report = await validatePluginTarget(target)
+ if (report.kind !== 'plugin') {
+ return report
+ }
+ const hashed = await hashPluginTree(report.path)
+ return hashed.ok
+ ? {
+ ...report,
+ contentHash: hashed.hash,
+ fileCount: hashed.fileCount,
+ totalBytes: hashed.totalBytes
+ }
+ : { ...report, valid: false, errors: [...report.errors, hashed.error] }
+}
diff --git a/src/cli/plugin-authoring/plugin-frame-templates.ts b/src/cli/plugin-authoring/plugin-frame-templates.ts
new file mode 100644
index 00000000000..e3e33afef1f
--- /dev/null
+++ b/src/cli/plugin-authoring/plugin-frame-templates.ts
@@ -0,0 +1,135 @@
+import {
+ COMMON_FILES,
+ manifest,
+ readme,
+ type PluginTemplate,
+ type PluginTemplateInput
+} from './plugin-template-files'
+
+// Sandboxed-frame templates: they run code, so loading them needs `--approve`.
+
+const FRAME_STYLE = `body {
+ margin: 0;
+ padding: 12px;
+ font: 13px system-ui, sans-serif;
+ color: var(--orca-foreground);
+ background: var(--orca-background);
+ }`
+
+export function editor(input: PluginTemplateInput): PluginTemplate {
+ const extension = `.${input.id.replace(/[^a-z0-9]/g, '').slice(0, 12) || 'note'}`
+ return {
+ description: 'Custom editor for a file type, in a sandboxed frame (needs --approve to load)',
+ files: {
+ 'orca-plugin.json': manifest(input, `Edits ${extension} files.`, {
+ execution: 'sandboxed-ui',
+ pluginApi: 1,
+ contributes: {
+ customEditors: [
+ {
+ id: input.id,
+ title: input.name,
+ extensions: [extension],
+ entry: 'editor/index.html'
+ }
+ ]
+ }
+ }),
+ 'editor/index.html': `
+
+
+
+
+
+
+
+
+
+
+`,
+ 'README.md': readme(
+ input,
+ `A custom editor for \`${extension}\` files. It runs in a sandboxed frame and talks to Orca only through \`window.orca\`. Loading it needs approval: \`orca plugin dev load\` prints the exact access and the \`--approve\` fingerprint.`
+ ),
+ ...COMMON_FILES
+ }
+ }
+}
+
+export function panel(input: PluginTemplateInput): PluginTemplate {
+ return {
+ description: 'Right-sidebar panel in a sandboxed frame (needs --approve to load)',
+ files: {
+ 'orca-plugin.json': manifest(input, 'A sidebar panel.', {
+ execution: 'sandboxed-ui',
+ pluginApi: 1,
+ contributes: {
+ panels: [{ id: input.id, title: input.name, icon: 'puzzle', entry: 'panel/index.html' }]
+ },
+ capabilities: [{ kind: 'workspace:read' }]
+ }),
+ 'panel/index.html': `
+
+
+
+
+
+
+ ${input.name.replace(/[<>&]/g, '')}
+ Loading…
+
+
+
+`,
+ 'README.md': readme(
+ input,
+ 'A right-sidebar panel. It runs in a sandboxed frame and calls host methods its `capabilities` allow through `window.orca`. Loading it needs approval: `orca plugin dev load` prints the exact access and the `--approve` fingerprint.'
+ ),
+ ...COMMON_FILES
+ }
+ }
+}
diff --git a/src/cli/plugin-authoring/plugin-template-files.ts b/src/cli/plugin-authoring/plugin-template-files.ts
new file mode 100644
index 00000000000..0e2d0d6d0c6
--- /dev/null
+++ b/src/cli/plugin-authoring/plugin-template-files.ts
@@ -0,0 +1,54 @@
+/** File helpers shared by the `orca plugin create` templates. */
+
+export type PluginTemplateInput = { publisher: string; id: string; name: string }
+
+export type PluginTemplate = {
+ description: string
+ /** Manifest-relative path (always `/`) to file text. */
+ files: Record
+}
+
+export const ENGINE = '>=1.4.245'
+
+export function json(value: unknown): string {
+ return `${JSON.stringify(value, null, 2)}\n`
+}
+
+export function manifest(
+ input: PluginTemplateInput,
+ description: string,
+ fields: Record
+): string {
+ return json({
+ manifestVersion: 2,
+ id: input.id,
+ publisher: input.publisher,
+ name: input.name,
+ version: '0.1.0',
+ description,
+ engines: { orca: ENGINE },
+ ...fields
+ })
+}
+
+export function readme(input: PluginTemplateInput, body: string): string {
+ return `# ${input.name}
+
+${body}
+
+## Develop
+
+\`\`\`sh
+orca plugin validate .
+orca plugin dev load . # loads this folder into the running Orca; edits reload automatically
+orca plugin logs ${input.publisher}.${input.id}
+orca plugin dev unload .
+\`\`\`
+
+Set \`publisher\` to the GitHub or GitLab user or organization that owns the repository before
+publishing. See https://github.com/stablyai/orca/blob/main/docs/reference/plugin-authoring.md.
+`
+}
+
+// Why -text: Git must not rewrite line endings, or the content hash differs per checkout.
+export const COMMON_FILES = { '.gitattributes': '* -text\n', '.gitignore': 'node_modules/\n' }
diff --git a/src/cli/plugin-authoring/plugin-template-names.ts b/src/cli/plugin-authoring/plugin-template-names.ts
new file mode 100644
index 00000000000..17b5f260021
--- /dev/null
+++ b/src/cli/plugin-authoring/plugin-template-names.ts
@@ -0,0 +1,16 @@
+// Kept apart from the template bodies so command specs load without them.
+export const PLUGIN_TEMPLATE_NAMES = [
+ 'theme',
+ 'terminal-theme',
+ 'icon-theme',
+ 'syntax',
+ 'language-pack',
+ 'editor',
+ 'panel'
+] as const
+
+export type PluginTemplateName = (typeof PLUGIN_TEMPLATE_NAMES)[number]
+
+export function isPluginTemplateName(value: string): value is PluginTemplateName {
+ return PLUGIN_TEMPLATE_NAMES.some((name) => name === value)
+}
diff --git a/src/cli/plugin-authoring/plugin-templates.test.ts b/src/cli/plugin-authoring/plugin-templates.test.ts
new file mode 100644
index 00000000000..301b48d5778
--- /dev/null
+++ b/src/cli/plugin-authoring/plugin-templates.test.ts
@@ -0,0 +1,75 @@
+import { mkdtemp, rm, writeFile } from 'node:fs/promises'
+import { tmpdir } from 'node:os'
+import { join } from 'node:path'
+import { afterEach, describe, expect, it } from 'vitest'
+import { createPluginFromTemplate, pluginIdFromFolder } from './plugin-create'
+import { inspectPluginFolder, validatePluginTarget } from './plugin-folder-validation'
+import { PLUGIN_TEMPLATE_NAMES } from './plugin-template-names'
+
+const roots: string[] = []
+
+async function tempRoot(): Promise {
+ const root = await mkdtemp(join(tmpdir(), 'orca-plugin-create-'))
+ roots.push(root)
+ return root
+}
+
+afterEach(async () => {
+ await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true })))
+})
+
+describe('orca plugin create templates', () => {
+ it.each(PLUGIN_TEMPLATE_NAMES)('%s scaffolds a plugin that validates as-is', async (template) => {
+ const dir = join(await tempRoot(), `my-${template}`)
+ const created = await createPluginFromTemplate({ dir, template, publisher: 'acme' })
+ expect(created.pluginKey).toBe(`acme.my-${template}`)
+ const report = await inspectPluginFolder(dir)
+ expect(report.errors).toEqual([])
+ expect(report.valid).toBe(true)
+ expect(report.contentHash).toMatch(/^[0-9a-f]{64}$/)
+ const contentOnly = ['theme', 'terminal-theme', 'icon-theme', 'syntax', 'language-pack']
+ expect(report.plugin?.needsApproval).toBe(!contentOnly.includes(template))
+ })
+
+ it('refuses a non-empty folder and never overwrites', async () => {
+ const dir = await tempRoot()
+ await writeFile(join(dir, 'keep.txt'), 'mine')
+ await expect(createPluginFromTemplate({ dir, template: 'theme' })).rejects.toThrow(
+ 'is not empty'
+ )
+ })
+
+ it('rejects an id the manifest would refuse before writing anything', async () => {
+ const dir = join(await tempRoot(), 'x')
+ await expect(
+ createPluginFromTemplate({ dir, template: 'theme', id: 'Bad Id' })
+ ).rejects.toThrow('cannot create')
+ })
+
+ it('derives the id from the folder name', () => {
+ expect(pluginIdFromFolder('/tmp/My Cool_Theme')).toBe('my-cool-theme')
+ })
+})
+
+describe('orca plugin validate', () => {
+ it('reports artifact errors Orca would otherwise skip silently', async () => {
+ const dir = join(await tempRoot(), 'broken')
+ await createPluginFromTemplate({ dir, template: 'theme', publisher: 'acme' })
+ await writeFile(
+ join(dir, 'themes', 'dark.json'),
+ JSON.stringify({ base: 'dark', tokens: { '--background': '#000', '--fooground': '#fff' } })
+ )
+ const report = await validatePluginTarget(dir)
+ expect(report.valid).toBe(false)
+ expect(report.errors.join('\n')).toContain('contributes.themes')
+ })
+
+ it('reports a missing panel entry', async () => {
+ const dir = join(await tempRoot(), 'panel')
+ await createPluginFromTemplate({ dir, template: 'panel', publisher: 'acme' })
+ await rm(join(dir, 'panel', 'index.html'))
+ const report = await validatePluginTarget(dir)
+ expect(report.valid).toBe(false)
+ expect(report.errors.join('\n')).toContain('panel/index.html')
+ })
+})
diff --git a/src/cli/plugin-authoring/plugin-templates.ts b/src/cli/plugin-authoring/plugin-templates.ts
new file mode 100644
index 00000000000..129873b5b2d
--- /dev/null
+++ b/src/cli/plugin-authoring/plugin-templates.ts
@@ -0,0 +1,23 @@
+/** Starter plugins for `orca plugin create`. Each one validates as-is and runs no build step. */
+
+import { iconTheme, languagePack, syntax, terminalTheme, theme } from './plugin-content-templates'
+import { editor, panel } from './plugin-frame-templates'
+import type { PluginTemplate, PluginTemplateInput } from './plugin-template-files'
+import type { PluginTemplateName } from './plugin-template-names'
+
+const TEMPLATES: Record PluginTemplate> = {
+ theme,
+ 'terminal-theme': terminalTheme,
+ 'icon-theme': iconTheme,
+ syntax,
+ 'language-pack': languagePack,
+ editor,
+ panel
+}
+
+export function renderPluginTemplate(
+ name: PluginTemplateName,
+ input: PluginTemplateInput
+): PluginTemplate {
+ return TEMPLATES[name](input)
+}
diff --git a/src/cli/remote-selection-scope.ts b/src/cli/remote-selection-scope.ts
index c635a85a9a4..633a7b6635e 100644
--- a/src/cli/remote-selection-scope.ts
+++ b/src/cli/remote-selection-scope.ts
@@ -10,6 +10,9 @@ export function ignoresRemoteSelection(commandPath: readonly string[]): boolean
(commandPath[0] === 'agent' && commandPath[1] !== 'launch') ||
commandPath[0] === 'vm' ||
commandPath[0] === 'agent-context' ||
- commandPath[0] === 'profile'
+ commandPath[0] === 'profile' ||
+ // Authoring works on local files; the handler refuses explicit remote flags.
+ (commandPath[0] === 'plugin' &&
+ ['create', 'validate', 'inspect'].includes(commandPath[1] ?? ''))
)
}
diff --git a/src/cli/root-help-text-primary.ts b/src/cli/root-help-text-primary.ts
index ca7fe9dfb3b..55132bfed61 100644
--- a/src/cli/root-help-text-primary.ts
+++ b/src/cli/root-help-text-primary.ts
@@ -46,6 +46,11 @@ export const ROOT_HELP_TEXT_PRIMARY = [
' environment show Show one saved remote Orca runtime',
' environment rm Remove a saved remote Orca runtime',
'',
+ 'Plugins:',
+ ' plugin create Scaffold a plugin from a template',
+ ' plugin validate Check a plugin folder, manifest or catalog',
+ ' plugin inspect Show what a plugin would get and its content hash',
+ '',
'Environment Recipes:',
' vm recipe doctor Validate a per-workspace environment recipe',
'',
diff --git a/src/cli/specs/index.ts b/src/cli/specs/index.ts
index c17bf1bac62..c8483cff550 100644
--- a/src/cli/specs/index.ts
+++ b/src/cli/specs/index.ts
@@ -27,6 +27,7 @@ import { SEARCH_COMMAND_SPECS } from './search'
import { PROFILE_STATE_COMMAND_SPECS } from './profile-state'
import { REFERENCE_COMMAND_SPECS } from './reference'
import { PORTS_COMMAND_SPECS } from './ports'
+import { PLUGIN_AUTHORING_COMMAND_SPECS } from './plugin'
export const COMMAND_SPECS: CommandSpec[] = [
...CORE_COMMAND_SPECS,
@@ -56,5 +57,6 @@ export const COMMAND_SPECS: CommandSpec[] = [
...SKILL_COMMAND_SPECS,
...SEARCH_COMMAND_SPECS,
...PROFILE_STATE_COMMAND_SPECS,
- ...PORTS_COMMAND_SPECS
+ ...PORTS_COMMAND_SPECS,
+ ...PLUGIN_AUTHORING_COMMAND_SPECS
]
diff --git a/src/cli/specs/plugin.ts b/src/cli/specs/plugin.ts
new file mode 100644
index 00000000000..94c0cabc22a
--- /dev/null
+++ b/src/cli/specs/plugin.ts
@@ -0,0 +1,56 @@
+import type { CommandSpec } from '../args'
+import { LOCAL_COMMAND_FLAGS } from '../args'
+import { PLUGIN_TEMPLATE_NAMES } from '../plugin-authoring/plugin-template-names'
+
+const TEMPLATES = PLUGIN_TEMPLATE_NAMES.join('|')
+
+const EXIT_CODES_NOTE =
+ 'Exit codes: 0 ok, 1 error, 2 the plugin is invalid (plugin_invalid), 3 approval is needed (approval_required or approval_stale).'
+
+export const PLUGIN_AUTHORING_COMMAND_SPECS: CommandSpec[] = [
+ {
+ path: ['plugin', 'create'],
+ summary: 'Scaffold a new Orca plugin from a template',
+ usage: `orca plugin create --template <${TEMPLATES}> [--publisher ] [--id ] [--name ] [--json]`,
+ allowedFlags: [...LOCAL_COMMAND_FLAGS, 'dir', 'template', 'publisher', 'id', 'name'],
+ positionalArgs: ['dir'],
+ notes: [
+ 'Writes into a new or empty folder (a .git folder is allowed) and never overwrites; it runs no Git, package install or plugin code.',
+ 'theme, terminal-theme, icon-theme, syntax and language-pack are content only: loading them never needs approval.',
+ 'editor and panel run code in a sandboxed frame: loading them prints the access they ask for and needs --approve.',
+ 'The id defaults to the folder name. Set --publisher to the GitHub or GitLab owner of the repository you will publish from.',
+ 'Runs on the machine that runs this command; it does not reach the Orca app.',
+ EXIT_CODES_NOTE
+ ],
+ examples: [
+ 'orca plugin create ./dusk-theme --template theme --publisher acme',
+ 'orca plugin create ./notes-editor --template editor --publisher acme --name "Notes" --json'
+ ]
+ },
+ {
+ path: ['plugin', 'validate'],
+ summary: 'Check a plugin folder, manifest or catalog the way Orca will load it',
+ usage: 'orca plugin validate [--json]',
+ allowedFlags: [...LOCAL_COMMAND_FLAGS, 'path'],
+ positionalArgs: ['path'],
+ notes: [
+ 'Runs the strict checks of `pnpm validate:plugin`: manifest, limits, execution profile, every theme, icon, syntax and translation file, and every file the manifest names.',
+ 'Offline; never runs plugin code. The result says whether loading needs --approve and what it would grant.',
+ EXIT_CODES_NOTE
+ ],
+ examples: ['orca plugin validate .', 'orca plugin validate ./dusk-theme --json']
+ },
+ {
+ path: ['plugin', 'inspect'],
+ summary: 'Show what a plugin folder contributes, the access it asks for, and its content hash',
+ usage: 'orca plugin inspect [--json]',
+ allowedFlags: [...LOCAL_COMMAND_FLAGS, 'path'],
+ positionalArgs: ['path'],
+ notes: [
+ 'Validates like `orca plugin validate`, then hashes the files the way install does.',
+ 'Use it to show your user exactly what a plugin would be allowed to do before loading it.',
+ EXIT_CODES_NOTE
+ ],
+ examples: ['orca plugin inspect ./notes-editor --json']
+ }
+]
diff --git a/src/main/plugins/plugin-authoring-checks.ts b/src/main/plugins/plugin-authoring-checks.ts
new file mode 100644
index 00000000000..5c6a3d4fce1
--- /dev/null
+++ b/src/main/plugins/plugin-authoring-checks.ts
@@ -0,0 +1,7 @@
+/** The main-process plugin checks `orca plugin validate|inspect` runs. One CLI entry so the
+ * packaged CLI loads them after electron-vite replaces out/main; keep it free of Electron. */
+export {
+ validateDeclaredPluginArtifacts,
+ validatePluginInstallContent
+} from './plugin-artifact-validation'
+export { hashPluginTree } from './plugin-content-hash'
diff --git a/src/main/startup/cli-command-names.ts b/src/main/startup/cli-command-names.ts
index 1d311552dfc..685f327cb4a 100644
--- a/src/main/startup/cli-command-names.ts
+++ b/src/main/startup/cli-command-names.ts
@@ -51,6 +51,7 @@ export const CLI_COMMAND_NAMES = [
'open-url',
'orchestration',
'pdf',
+ 'plugin',
'ports',
'profile',
'project',
diff --git a/src/shared/plugins/authoring/plugin-strict-artifact-checks.ts b/src/shared/plugins/authoring/plugin-strict-artifact-checks.ts
new file mode 100644
index 00000000000..d0476d3d665
--- /dev/null
+++ b/src/shared/plugins/authoring/plugin-strict-artifact-checks.ts
@@ -0,0 +1,126 @@
+import { z } from 'zod'
+import {
+ isPlausiblePluginIconSvg,
+ parsePluginIconThemeArtifact
+} from '../artifacts/plugin-icon-theme-artifact'
+import { parsePluginLanguagePackArtifact } from '../artifacts/plugin-language-pack-artifact'
+import {
+ parsePluginGrammarArtifact,
+ parsePluginLanguageConfiguration
+} from '../artifacts/plugin-syntax-artifact'
+import { parsePluginTerminalThemeArtifact } from '../artifacts/plugin-terminal-theme-artifact'
+import { parsePluginAppThemeArtifact } from '../artifacts/plugin-theme-artifact'
+import type { PluginManifest } from '../manifest/plugin-manifest'
+import { formatPluginSchemaIssues } from '../manifest/plugin-schema-issues'
+
+/** Reads a plugin file by its manifest path (always `/`); null when it does not exist. */
+export type PluginArtifactTextReader = (manifestPath: string) => string | null
+
+const THEME_POINTS = ['themes', 'terminalThemes', 'iconThemes'] as const
+const THEME_PARSERS: Record<(typeof THEME_POINTS)[number], (raw: string) => unknown> = {
+ themes: (raw) => parsePluginAppThemeArtifact(raw, 'strict'),
+ terminalThemes: (raw) => parsePluginTerminalThemeArtifact(raw, 'strict'),
+ iconThemes: (raw) => parsePluginIconThemeArtifact(raw, 'strict')
+}
+
+// Why: Orca drops an icon theme whose SVG is missing or not an SVG only once a user picks it.
+function iconSvgErrors(read: PluginArtifactTextReader, themeText: string | null): string[] {
+ if (themeText === null) {
+ return []
+ }
+ let artifact: ReturnType
+ try {
+ artifact = parsePluginIconThemeArtifact(themeText, 'strict')
+ } catch {
+ return []
+ }
+ const paths = new Set([
+ ...Object.values(artifact.icons),
+ ...Object.values(artifact.fileNames),
+ ...Object.values(artifact.fileExtensions)
+ ])
+ return [...paths].flatMap((path) => {
+ const svg = read(path)
+ if (svg === null) {
+ return [`${path}: file not found`]
+ }
+ return isPlausiblePluginIconSvg(svg) ? [] : [`${path}: not an SVG image`]
+ })
+}
+
+function detail(error: unknown, text: string | null): string {
+ if (error instanceof z.ZodError) {
+ let input: unknown = null
+ try {
+ input = text === null ? null : JSON.parse(text)
+ } catch {
+ input = null
+ }
+ return formatPluginSchemaIssues(error.issues, input)
+ }
+ return error instanceof Error ? error.message : String(error)
+}
+
+function check(read: PluginArtifactTextReader, path: string, parse: (text: string) => void) {
+ const text = read(path)
+ if (text === null) {
+ return 'file not found'
+ }
+ try {
+ parse(text)
+ return null
+ } catch (error) {
+ return detail(error, text)
+ }
+}
+
+/**
+ * Author-time checks of every content artifact, strictly. Why: Orca skips unknown theme tokens,
+ * icon slots, protected language-pack copy and unsafe syntax settings at load, so authors would
+ * only learn about a typo here.
+ */
+export function strictPluginArtifactErrors(
+ manifest: PluginManifest,
+ read: PluginArtifactTextReader
+): string[] {
+ const c = manifest.contributes
+ const themeErrors = THEME_POINTS.flatMap((point) =>
+ c[point].flatMap((contribution) => {
+ const error = check(read, contribution.path, THEME_PARSERS[point])
+ return error
+ ? [`contributes.${point} "${contribution.id}" (${contribution.path}): ${error}`]
+ : []
+ })
+ )
+ const packErrors = c.languagePacks.flatMap((pack) => {
+ const error = check(read, pack.path, (text) => {
+ const parsed = parsePluginLanguagePackArtifact(text, 'strict')
+ if (!parsed.ok) {
+ throw new Error(parsed.error)
+ }
+ })
+ return error ? [`contributes.languagePacks "${pack.locale}" (${pack.path}): ${error}`] : []
+ })
+ const syntaxErrors = c.syntaxes.flatMap((syntax) => {
+ const grammar = check(read, syntax.path, (text) =>
+ parsePluginGrammarArtifact(text, syntax.scopeName)
+ )
+ const configuration = syntax.configuration
+ ? check(read, syntax.configuration, (text) =>
+ parsePluginLanguageConfiguration(text, 'strict')
+ )
+ : null
+ return [
+ ...(grammar ? [`contributes.syntaxes "${syntax.id}" (${syntax.path}): ${grammar}`] : []),
+ ...(configuration
+ ? [`contributes.syntaxes "${syntax.id}" (${syntax.configuration}): ${configuration}`]
+ : [])
+ ]
+ })
+ const svgErrors = c.iconThemes.flatMap((theme) =>
+ iconSvgErrors(read, read(theme.path)).map(
+ (error) => `contributes.iconThemes "${theme.id}" (${theme.path}): ${error}`
+ )
+ )
+ return [...themeErrors, ...svgErrors, ...packErrors, ...syntaxErrors]
+}