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
This commit is contained in:
Neil
2026-10-30 01:00:15 -07:00
committed by GitHub
parent 5dd43d0146
commit 1963ac0ea8
27 changed files with 1154 additions and 77 deletions
+5 -5
View File
@@ -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)
+20
View File
@@ -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)
})
})
+13
View File
@@ -0,0 +1,13 @@
import { cliErrorCode } from './cli-error'
const PLUGIN_EXIT_CODES: Readonly<Record<string, number>> = {
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
}
+5
View File
@@ -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'],
+135
View File
@@ -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<string, string | boolean>, 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 <fingerprint>; 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<T extends PluginValidationReport>(
read: (path: string) => Promise<T>,
path: string
): Promise<T> {
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<string, CommandHandler> = {
'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 <dir>'
].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))
}
}
+2 -1
View File
@@ -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)
}
}
@@ -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)
}
@@ -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 = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill="#82cfff" d="M3 1h7l3 3v11H3z"/></svg>\n`
const FOLDER_ICON = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill="#ffd479" d="M1 3h5l2 2h7v9H1z"/></svg>\n`
const MARKDOWN_ICON = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill="#b48cff" d="M3 1h7l3 3v11H3z"/></svg>\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
}
}
}
+79
View File
@@ -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<PluginCreateResult> {
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
}))
@@ -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<string[]> {
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<PluginValidationReport> {
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<PluginInspection> {
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] }
}
@@ -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': `<!doctype html>
<html>
<head>
<meta charset="utf-8" />
<style>
${FRAME_STYLE}
textarea {
box-sizing: border-box;
width: 100%;
height: calc(100vh - 24px);
color: inherit;
background: transparent;
border: 1px solid var(--orca-border);
font: 13px ui-monospace, monospace;
}
</style>
</head>
<body>
<textarea id="text" spellcheck="false"></textarea>
<script>
const text = document.getElementById('text')
let version = 0
// Orca sends the document now and again whenever the file changes outside the editor.
orca.document.onDidChange((doc) => {
version = doc.version
text.readOnly = doc.readOnly
if (doc.content.kind === 'text' && text.value !== doc.content.text) {
text.value = doc.content.text
}
})
text.addEventListener('input', () => {
void orca.document
.applyEdit({ baseVersion: version, content: { kind: 'text', text: text.value } })
.catch((error) => console.warn(error.code, error.message))
})
</script>
</body>
</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': `<!doctype html>
<html>
<head>
<meta charset="utf-8" />
<style>
${FRAME_STYLE}
</style>
</head>
<body>
<h1>${input.name.replace(/[<>&]/g, '')}</h1>
<p id="workspace">Loading…</p>
<script>
const label = document.getElementById('workspace')
async function refresh() {
if (!orca.host.has('workspace.readContext')) {
label.textContent = 'This Orca cannot read workspace context.'
return
}
try {
const context = await orca.host.call('workspace.readContext', {})
label.textContent = JSON.stringify(context, null, 2)
} catch (error) {
label.textContent = error.code + ': ' + error.message
}
}
void refresh()
</script>
</body>
</html>
`,
'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
}
}
}
@@ -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<string, string>
}
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, unknown>
): 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' }
@@ -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)
}
@@ -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<string> {
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')
})
})
@@ -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<PluginTemplateName, (input: PluginTemplateInput) => 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)
}
+4 -1
View File
@@ -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] ?? ''))
)
}
+5
View File
@@ -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',
'',
+3 -1
View File
@@ -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
]
+56
View File
@@ -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 <dir> --template <${TEMPLATES}> [--publisher <name>] [--id <id>] [--name <display 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 <dir|orca-plugin.json|orca-catalog.json> [--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 <dir> [--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']
}
]