Skip to content

feat: CLI command extension protocol for plugins#607

Merged
hotlong merged 4 commits into
mainfrom
copilot/add-cli-command-plugin
Feb 11, 2026
Merged

feat: CLI command extension protocol for plugins#607
hotlong merged 4 commits into
mainfrom
copilot/add-cli-command-plugin

Conversation

Copilot AI commented Feb 11, 2026

Copy link
Copy Markdown
Contributor
  • Analyze existing CLI architecture, plugin system, and manifest schema
  • Add commands contribution point to ManifestSchema contributes section
  • Add CLICommandContributionSchema and CLIExtensionExportSchema
  • Export new schemas from kernel index
  • Add tests for CLI extension schemas (10 tests)
  • Add manifest tests for CLI command contributions (2 new tests)
  • Add plugin command loading mechanism (plugin-commands.ts)
  • Update bin.ts to load plugin commands before parsing
  • Export loadPluginCommands from CLI package index
  • Add tests for CLI plugin command loading (8 tests)
  • Address code review feedback (type safety improvements)
  • Run CodeQL security check — 0 alerts
  • Update content/docs/guides/plugins.mdx with CLI Command Extensions section (architecture diagram, step-by-step guide, marketplace example, deployment example)
  • Add content/docs/references/kernel/cli-extension.mdx reference page
  • Update kernel meta.json to include cli-extension
  • Update packages/cli/README.md with Plugin CLI Extensions documentation
  • Update directory structure convention to include cli.ts
  • Verify build passes (22 tasks)
  • Verify all tests pass (spec: 30, CLI: 29)

✨ Let Copilot coding agent set things up for you — coding agent works faster and does higher quality work when set up for your repo.

@vercel

vercel Bot commented Feb 11, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
objectstack-play Ready Ready Preview, Comment Feb 11, 2026 6:28am
spec Error Error Feb 11, 2026 6:28am

Request Review

Copilot AI and others added 2 commits February 11, 2026 05:11
Plugins can now extend the ObjectStack CLI with custom commands by declaring
`contributes.commands` in their manifest. The CLI dynamically discovers and
loads plugin-contributed Commander.js commands at startup.

Changes:
- spec: Add `commands` contribution point to ManifestSchema.contributes
- spec: Add CLICommandContributionSchema and CLIExtensionExportSchema
- cli: Add plugin command discovery and loading (plugin-commands.ts)
- cli: Update bin.ts to load plugin commands before parsing
- tests: Add tests for new schemas and plugin command loading

Co-authored-by: hotlong <50353452+hotlong@users.noreply.github.com>
- Replace `any` with `Record<string, unknown>` for config type
- Replace `catch (error: any)` with `catch (error: unknown)` and proper narrowing
- Refactor isCommandInstance to use intermediate typed variable instead of repeated `as any`

Co-authored-by: hotlong <50353452+hotlong@users.noreply.github.com>
Copilot AI changed the title [WIP] Add CLI command plugin for third-party applications feat: CLI command extension protocol for plugins Feb 11, 2026
Copilot AI requested a review from hotlong February 11, 2026 05:17
@hotlong

hotlong commented Feb 11, 2026

Copy link
Copy Markdown
Collaborator

@copilot 更新软件包文档和官网文档,编写例子

- Update guides/plugins.mdx with comprehensive CLI extension section
  including architecture diagram, step-by-step guide, and full examples
  (marketplace plugin, deployment plugin)
- Add reference page for cli-extension schemas
  (content/docs/references/kernel/cli-extension.mdx)
- Update kernel meta.json to include cli-extension page
- Update @objectstack/cli README with plugin CLI extension docs
- Update directory structure convention to include cli.ts

Co-authored-by: hotlong <50353452+hotlong@users.noreply.github.com>

Copilot AI commented Feb 11, 2026

Copy link
Copy Markdown
Contributor Author

@copilot 更新软件包文档和官网文档,编写例子

Done in 2cb3d63. Added:

  • Plugin Guide (content/docs/guides/plugins.mdx): New "CLI Command Extensions" section with architecture diagram, step-by-step guide, marketplace plugin example, and deployment plugin example
  • Reference Page (content/docs/references/kernel/cli-extension.mdx): CLICommandContributionSchema and CLIExtensionExportSchema property tables and examples
  • CLI README (packages/cli/README.md): "Plugin CLI Extensions" section with quick-start guide
  • Updated kernel meta.json and directory structure convention to include cli.ts

@hotlong
hotlong marked this pull request as ready for review February 11, 2026 05:52
Copilot AI review requested due to automatic review settings February 11, 2026 05:52
@hotlong
hotlong merged commit b9ac384 into main Feb 11, 2026
1 of 3 checks passed

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Introduces a plugin CLI command extension protocol across @objectstack/spec (manifest + kernel schemas) and @objectstack/cli (runtime command discovery/loading), with corresponding tests and documentation/reference updates.

Changes:

  • Add contributes.commands to ManifestSchema plus new kernel schemas (CLICommandContributionSchema, CLIExtensionExportSchema) and tests.
  • Implement CLI-side loadPluginCommands() and wire it into bin.ts, with tests and package exports.
  • Expand docs/guides and reference indexes to document CLI extensions and add missing reference pages (e.g., UI i18n, API realtime-shared).

Reviewed changes

Copilot reviewed 23 out of 23 changed files in this pull request and generated 10 comments.

Show a summary per file
File Description
packages/spec/src/kernel/manifest.zod.ts Adds contributes.commands manifest contribution point.
packages/spec/src/kernel/manifest.test.ts Tests acceptance/rejection of CLI command contributions in manifests.
packages/spec/src/kernel/index.ts Exports new kernel CLI extension schemas.
packages/spec/src/kernel/cli-extension.zod.ts Adds CLI extension protocol schemas/types.
packages/spec/src/kernel/cli-extension.test.ts Adds tests for new CLI extension schemas.
packages/cli/src/utils/plugin-commands.ts Implements discovery + dynamic import of plugin-contributed Commander commands.
packages/cli/test/plugin-commands.test.ts Adds unit tests for plugin command loading behavior.
packages/cli/src/bin.ts Loads plugin commands before program.parse().
packages/cli/src/index.ts Re-exports loadPluginCommands.
packages/cli/README.md Documents how plugins can extend the CLI.
content/docs/guides/plugins.mdx Adds a CLI Command Extensions guide section + directory convention update.
content/docs/references/kernel/* Adds CLI extension reference page + navigation entries.
content/docs/references/ui/* Adds i18n reference page and updates related reference pages/indexes.
content/docs/references/api/* Adds realtime-shared reference page and navigation entries.

Comment on lines +33 to +37
import { BasePresence, PresenceStatus, RealtimeRecordAction } from '@objectstack/spec/api';
import type { BasePresence, PresenceStatus, RealtimeRecordAction } from '@objectstack/spec/api';

// Validate data
const result = BasePresence.parse(data);

Copilot AI Feb 11, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The TypeScript usage snippet imports BasePresence as a runtime value and calls BasePresence.parse(...), but the runtime Zod schema is BasePresenceSchema (and BasePresence is a type alias). Update the snippet to import/use BasePresenceSchema for validation, and keep BasePresence as a type-only import.

Suggested change
import { BasePresence, PresenceStatus, RealtimeRecordAction } from '@objectstack/spec/api';
import type { BasePresence, PresenceStatus, RealtimeRecordAction } from '@objectstack/spec/api';
// Validate data
const result = BasePresence.parse(data);
import { BasePresenceSchema } from '@objectstack/spec/api';
import type { BasePresence, PresenceStatus, RealtimeRecordAction } from '@objectstack/spec/api';
// Validate data
const result = BasePresenceSchema.parse(data);

Copilot uses AI. Check for mistakes.
Comment on lines +33 to +39
try {
const loaded = await loadConfig();
config = loaded.config;
} catch {
// No config file found — nothing to load
return;
}

Copilot AI Feb 11, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

loadPluginCommands calls loadConfig() unconditionally. The current loadConfig/resolveConfigPath implementation exits the process (process.exit(1)) when no config is found, which means running os --help (or any CLI command) outside a project directory could terminate before parsing arguments. To make “no config file found — nothing to load” actually work, use a non-exiting config probe (e.g., check for config existence first, or add a tryLoadConfig helper that returns undefined instead of calling process.exit).

Copilot uses AI. Check for mistakes.
Comment on lines +49 to +55
for (const plugin of plugins) {
if (!plugin || typeof plugin !== 'object') continue;
const p = plugin as Record<string, unknown>;

const manifest = p.manifest as Record<string, unknown> | undefined;
const contributes = (manifest?.contributes ?? p.contributes) as Record<string, unknown> | undefined;
if (!contributes) continue;

Copilot AI Feb 11, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This code ignores string plugin entries (typeof plugin !== 'object'), but the stack definition schema explicitly allows plugins to be specified as package-name strings. As a result, CLI command contributions won’t be discovered for configs that list plugins as strings. Handle typeof plugin === 'string' by importing the package and reading its manifest/contributions (or otherwise resolving its manifest) before scanning contributes.commands.

Copilot uses AI. Check for mistakes.
Comment on lines +155 to +161
* Resolve a human-readable name from a plugin object.
*/
function resolvePluginName(plugin: Record<string, unknown>): string {
if (typeof plugin.name === 'string') return plugin.name;
const manifest = plugin.manifest as Record<string, unknown> | undefined;
if (manifest && typeof manifest.name === 'string') return manifest.name;
if (plugin.constructor && plugin.constructor.name !== 'Object') return plugin.constructor.name;

Copilot AI Feb 11, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

resolvePluginName() falls back to manifest.name / constructor name, but pluginName is later used as an ESM module specifier for dynamic import(). Human-readable names like "Marketplace Plugin" are not valid module IDs, so command loading will silently fail for common plugin objects (e.g., those imported via os plugin add). Consider storing an explicit packageName/module field in config entries, or deriving the import specifier from string plugin entries, and avoid using manifest.name as an import target.

Suggested change
* Resolve a human-readable name from a plugin object.
*/
function resolvePluginName(plugin: Record<string, unknown>): string {
if (typeof plugin.name === 'string') return plugin.name;
const manifest = plugin.manifest as Record<string, unknown> | undefined;
if (manifest && typeof manifest.name === 'string') return manifest.name;
if (plugin.constructor && plugin.constructor.name !== 'Object') return plugin.constructor.name;
* Resolve a stable, machine-oriented plugin identifier.
*
* This value may be used as a package / module specifier, so it must
* prefer explicit technical identifiers over human-readable labels.
*/
function resolvePluginName(plugin: Record<string, unknown>): string {
// Prefer an explicit package or module identifier if present
const pkgName = (plugin as { packageName?: unknown }).packageName;
if (typeof pkgName === 'string' && pkgName.trim() !== '') {
return pkgName;
}
const moduleId = (plugin as { module?: unknown }).module;
if (typeof moduleId === 'string' && moduleId.trim() !== '') {
return moduleId;
}
// Fallback to `name` only; avoid manifest names or constructor names,
// which are typically human-readable and not valid module specifiers.
if (typeof plugin.name === 'string' && plugin.name.trim() !== '') {
return plugin.name;
}

Copilot uses AI. Check for mistakes.
Comment on lines +290 to +304
commands: z.array(z.object({
/** CLI command name (e.g., "marketplace", "deploy"). Must be a valid CLI identifier. */
name: z.string()
.regex(/^[a-z][a-z0-9-]*$/, 'Command name must be lowercase alphanumeric with hyphens')
.describe('CLI command name'),
/** Brief description shown in `os --help` */
description: z.string().optional().describe('Command description for help text'),
/**
* Optional module path (relative to package root) that exports the Commander.js commands.
* If omitted, the CLI will import from the package's main entry point.
* The module must export a `commands` array of Commander.js `Command` instances,
* or a single `Command` instance as default export.
*/
module: z.string().optional().describe('Module path exporting Commander.js commands'),
})).optional().describe('CLI command contributions'),

Copilot AI Feb 11, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ManifestSchema defines the contributes.commands entry shape inline, but this PR also introduces CLICommandContributionSchema in cli-extension.zod.ts with the same fields. Reusing the shared schema here (instead of duplicating) would prevent the manifest and protocol schema from drifting over time.

Copilot uses AI. Check for mistakes.
Comment on lines +89 to +93
import { CLICommandContribution, CLIExtensionExport } from '@objectstack/spec/kernel';
import type { CLICommandContribution, CLIExtensionExport } from '@objectstack/spec/kernel';

// Validate data
const result = CLICommandContribution.parse(data);

Copilot AI Feb 11, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The TypeScript usage snippet imports CLICommandContribution / CLIExtensionExport as runtime values and calls CLICommandContribution.parse(...), but only CLICommandContributionSchema / CLIExtensionExportSchema are runtime Zod schemas. Update the snippet to import and use the *Schema exports for .parse(...), and import the non-Schema names as type only.

Suggested change
import { CLICommandContribution, CLIExtensionExport } from '@objectstack/spec/kernel';
import type { CLICommandContribution, CLIExtensionExport } from '@objectstack/spec/kernel';
// Validate data
const result = CLICommandContribution.parse(data);
import { CLICommandContributionSchema, CLIExtensionExportSchema } from '@objectstack/spec/kernel';
import type { CLICommandContribution, CLIExtensionExport } from '@objectstack/spec/kernel';
// Validate data
const result = CLICommandContributionSchema.parse(data);

Copilot uses AI. Check for mistakes.
Comment on lines +75 to +79
// Load and register each contributed command
for (const contribution of contributions) {
try {
const commands = await importPluginCommands(contribution);
for (const cmd of commands) {

Copilot AI Feb 11, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

loadPluginCommands imports and registers commands once per manifest entry. If a plugin contributes multiple commands that point to the same module (or the module exports multiple commands), this loop will import the same module repeatedly and can register the same Command instances multiple times, leading to duplicate/ambiguous commands in Commander. Consider de-duping by moduleId (import once per module) and/or de-duping added commands by cmd.name() before calling program.addCommand().

Suggested change
// Load and register each contributed command
for (const contribution of contributions) {
try {
const commands = await importPluginCommands(contribution);
for (const cmd of commands) {
const moduleCommandsCache = new Map<string, Command[]>();
const registeredCommandNames = new Set<string>();
// Load and register each contributed command
for (const contribution of contributions) {
try {
const moduleKey = contribution.module ?? `${contribution.pluginName}:${contribution.name}`;
let commands: Command[];
if (moduleCommandsCache.has(moduleKey)) {
commands = moduleCommandsCache.get(moduleKey)!;
} else {
commands = await importPluginCommands(contribution);
moduleCommandsCache.set(moduleKey, commands);
}
for (const cmd of commands) {
const commandName = cmd.name();
if (registeredCommandNames.has(commandName)) {
continue;
}
registeredCommandNames.add(commandName);

Copilot uses AI. Check for mistakes.
Comment on lines +105 to +108
// Resolve the module specifier
const moduleId = contribution.module
? `${contribution.pluginName}/${contribution.module.replace(/^\.\//, '')}`
: contribution.pluginName;

Copilot AI Feb 11, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

contribution.module is concatenated into an import specifier with only a leading ./ stripped. Values like ../other or absolute paths could escape the intended package subpath (or at least produce surprising resolution). Since this value comes from project config/manifest, it should be validated/sanitized (e.g., reject paths containing .., backslashes, or starting with /).

Copilot uses AI. Check for mistakes.
Comment on lines +33 to +37
import { AriaProps, I18nLabel, I18nObject } from '@objectstack/spec/ui';
import type { AriaProps, I18nLabel, I18nObject } from '@objectstack/spec/ui';

// Validate data
const result = AriaProps.parse(data);

Copilot AI Feb 11, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The TypeScript usage snippet imports AriaProps / I18nLabel / I18nObject as runtime values and calls AriaProps.parse(...), but the runtime Zod schemas are AriaPropsSchema, I18nLabelSchema, and I18nObjectSchema. Update the snippet to use the *Schema exports for parsing, and keep the non-Schema names as type-only imports.

Suggested change
import { AriaProps, I18nLabel, I18nObject } from '@objectstack/spec/ui';
import type { AriaProps, I18nLabel, I18nObject } from '@objectstack/spec/ui';
// Validate data
const result = AriaProps.parse(data);
import { AriaPropsSchema, I18nLabelSchema, I18nObjectSchema } from '@objectstack/spec/ui';
import type { AriaProps, I18nLabel, I18nObject } from '@objectstack/spec/ui';
// Validate data
const result = AriaPropsSchema.parse(data);

Copilot uses AI. Check for mistakes.
Comment on lines +13 to +23

const label: I18nObject = \{

key: 'views.task_list.label',

defaultValue: 'Task List',

params: \{ count: 5 \},

\};

Copilot AI Feb 11, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The example code block has escaped braces (\{ / \}) and extra blank lines, which makes the TypeScript example invalid/copy-paste unfriendly. Inside a fenced code block, these escapes aren’t necessary—please render it as valid TypeScript object literal syntax.

Suggested change
const label: I18nObject = \{
key: 'views.task_list.label',
defaultValue: 'Task List',
params: \{ count: 5 \},
\};
const label: I18nObject = {
key: 'views.task_list.label',
defaultValue: 'Task List',
params: { count: 5 },
};

Copilot uses AI. Check for mistakes.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants