/** * CLI command wrappers for plugin operations * * This module provides thin wrappers around the core plugin operations * that handle CLI-specific concerns like console output and process exit. * * For the core operations (without CLI side effects), see pluginOperations.ts */ import figures from 'figures' import { errorMessage } from '../../utils/errors.js' import { gracefulShutdown } from '../../utils/gracefulShutdown.js' import { logError } from '../../utils/log.js' import { getManagedPluginNames } from '../../utils/plugins/managedPlugins.js' import { parsePluginIdentifier } from '../../utils/plugins/pluginIdentifier.js' import type { PluginScope } from '../../utils/plugins/schemas.js' import { writeToStdout } from '../../utils/process.js' import { buildPluginTelemetryFields, classifyPluginCommandError, } from '../../utils/telemetry/pluginTelemetry.js' import { type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, type AnalyticsMetadata_I_VERIFIED_THIS_IS_PII_TAGGED, logEvent, } from '../analytics/index.js' import { disableAllPluginsOp, disablePluginOp, enablePluginOp, type InstallableScope, installPluginOp, uninstallPluginOp, updatePluginOp, VALID_INSTALLABLE_SCOPES, VALID_UPDATE_SCOPES, } from './pluginOperations.js' export { VALID_INSTALLABLE_SCOPES, VALID_UPDATE_SCOPES } type PluginCliCommand = | 'install' | 'uninstall' | 'enable' | 'disable' | 'disable-all' | 'update' /** * Generic error handler for plugin CLI commands. Emits * tengu_plugin_command_failed before exit so dashboards can compute a * success rate against the corresponding success events. */ function handlePluginCommandError( error: unknown, command: PluginCliCommand, plugin?: string, ): never { logError(error) const operation = plugin ? `${command} plugin "${plugin}"` : command === 'disable-all' ? 'disable all plugins' : `${command} plugins` // biome-ignore lint/suspicious/noConsole:: intentional console output console.error( `${figures.cross} Failed to ${operation}: ${errorMessage(error)}`, ) const telemetryFields = plugin ? (() => { const { name, marketplace } = parsePluginIdentifier(plugin) return { _PROTO_plugin_name: name as AnalyticsMetadata_I_VERIFIED_THIS_IS_PII_TAGGED, ...(marketplace && { _PROTO_marketplace_name: marketplace as AnalyticsMetadata_I_VERIFIED_THIS_IS_PII_TAGGED, }), ...buildPluginTelemetryFields( name, marketplace, getManagedPluginNames(), ), } })() : {} logEvent('tengu_plugin_command_failed', { command: command as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, error_category: classifyPluginCommandError( error, ) as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, ...telemetryFields, }) // eslint-disable-next-line custom-rules/no-process-exit process.exit(1) } /** * CLI command: Install a plugin non-interactively * @param plugin Plugin identifier (name or plugin@marketplace) * @param scope Installation scope: user, project, or local (defaults to 'user') */ export async function installPlugin( plugin: string, scope: InstallableScope = 'user', ): Promise { try { // biome-ignore lint/suspicious/noConsole:: intentional console output console.log(`Installing plugin "${plugin}"...`) const result = await installPluginOp(plugin, scope) if (!result.success) { throw new Error(result.message) } // biome-ignore lint/suspicious/noConsole:: intentional console output console.log(`${figures.tick} ${result.message}`) // _PROTO_* routes to PII-tagged plugin_name/marketplace_name BQ columns. // Unredacted plugin_id was previously logged to general-access // additional_metadata for all users — dropped in favor of the privileged // column route. const { name, marketplace } = parsePluginIdentifier( result.pluginId || plugin, ) logEvent('tengu_plugin_installed_cli', { _PROTO_plugin_name: name as AnalyticsMetadata_I_VERIFIED_THIS_IS_PII_TAGGED, ...(marketplace && { _PROTO_marketplace_name: marketplace as AnalyticsMetadata_I_VERIFIED_THIS_IS_PII_TAGGED, }), scope: (result.scope || scope) as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, install_source: 'cli-explicit' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, ...buildPluginTelemetryFields(name, marketplace, getManagedPluginNames()), }) // eslint-disable-next-line custom-rules/no-process-exit process.exit(0) } catch (error) { handlePluginCommandError(error, 'install', plugin) } } /** * CLI command: Uninstall a plugin non-interactively * @param plugin Plugin name or plugin@marketplace identifier * @param scope Uninstall from scope: user, project, or local (defaults to 'user') */ export async function uninstallPlugin( plugin: string, scope: InstallableScope = 'user', keepData = false, ): Promise { try { const result = await uninstallPluginOp(plugin, scope, !keepData) if (!result.success) { throw new Error(result.message) } // biome-ignore lint/suspicious/noConsole:: intentional console output console.log(`${figures.tick} ${result.message}`) const { name, marketplace } = parsePluginIdentifier( result.pluginId || plugin, ) logEvent('tengu_plugin_uninstalled_cli', { _PROTO_plugin_name: name as AnalyticsMetadata_I_VERIFIED_THIS_IS_PII_TAGGED, ...(marketplace && { _PROTO_marketplace_name: marketplace as AnalyticsMetadata_I_VERIFIED_THIS_IS_PII_TAGGED, }), scope: (result.scope || scope) as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, ...buildPluginTelemetryFields(name, marketplace, getManagedPluginNames()), }) // eslint-disable-next-line custom-rules/no-process-exit process.exit(0) } catch (error) { handlePluginCommandError(error, 'uninstall', plugin) } } /** * CLI command: Enable a plugin non-interactively * @param plugin Plugin name or plugin@marketplace identifier * @param scope Optional scope. If not provided, finds the most specific scope for the current project. */ export async function enablePlugin( plugin: string, scope?: InstallableScope, ): Promise { try { const result = await enablePluginOp(plugin, scope) if (!result.success) { throw new Error(result.message) } // biome-ignore lint/suspicious/noConsole:: intentional console output console.log(`${figures.tick} ${result.message}`) const { name, marketplace } = parsePluginIdentifier( result.pluginId || plugin, ) logEvent('tengu_plugin_enabled_cli', { _PROTO_plugin_name: name as AnalyticsMetadata_I_VERIFIED_THIS_IS_PII_TAGGED, ...(marketplace && { _PROTO_marketplace_name: marketplace as AnalyticsMetadata_I_VERIFIED_THIS_IS_PII_TAGGED, }), scope: result.scope as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, ...buildPluginTelemetryFields(name, marketplace, getManagedPluginNames()), }) // eslint-disable-next-line custom-rules/no-process-exit process.exit(0) } catch (error) { handlePluginCommandError(error, 'enable', plugin) } } /** * CLI command: Disable a plugin non-interactively * @param plugin Plugin name or plugin@marketplace identifier * @param scope Optional scope. If not provided, finds the most specific scope for the current project. */ export async function disablePlugin( plugin: string, scope?: InstallableScope, ): Promise { try { const result = await disablePluginOp(plugin, scope) if (!result.success) { throw new Error(result.message) } // biome-ignore lint/suspicious/noConsole:: intentional console output console.log(`${figures.tick} ${result.message}`) const { name, marketplace } = parsePluginIdentifier( result.pluginId || plugin, ) logEvent('tengu_plugin_disabled_cli', { _PROTO_plugin_name: name as AnalyticsMetadata_I_VERIFIED_THIS_IS_PII_TAGGED, ...(marketplace && { _PROTO_marketplace_name: marketplace as AnalyticsMetadata_I_VERIFIED_THIS_IS_PII_TAGGED, }), scope: result.scope as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, ...buildPluginTelemetryFields(name, marketplace, getManagedPluginNames()), }) // eslint-disable-next-line custom-rules/no-process-exit process.exit(0) } catch (error) { handlePluginCommandError(error, 'disable', plugin) } } /** * CLI command: Disable all enabled plugins non-interactively */ export async function disableAllPlugins(): Promise { try { const result = await disableAllPluginsOp() if (!result.success) { throw new Error(result.message) } // biome-ignore lint/suspicious/noConsole:: intentional console output console.log(`${figures.tick} ${result.message}`) logEvent('tengu_plugin_disabled_all_cli', {}) // eslint-disable-next-line custom-rules/no-process-exit process.exit(0) } catch (error) { handlePluginCommandError(error, 'disable-all') } } /** * CLI command: Update a plugin non-interactively * @param plugin Plugin name or plugin@marketplace identifier * @param scope Scope to update */ export async function updatePluginCli( plugin: string, scope: PluginScope, ): Promise { try { writeToStdout( `Checking for updates for plugin "${plugin}" at ${scope} scope…\n`, ) const result = await updatePluginOp(plugin, scope) if (!result.success) { throw new Error(result.message) } writeToStdout(`${figures.tick} ${result.message}\n`) if (!result.alreadyUpToDate) { const { name, marketplace } = parsePluginIdentifier( result.pluginId || plugin, ) logEvent('tengu_plugin_updated_cli', { _PROTO_plugin_name: name as AnalyticsMetadata_I_VERIFIED_THIS_IS_PII_TAGGED, ...(marketplace && { _PROTO_marketplace_name: marketplace as AnalyticsMetadata_I_VERIFIED_THIS_IS_PII_TAGGED, }), old_version: (result.oldVersion || 'unknown') as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, new_version: (result.newVersion || 'unknown') as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, ...buildPluginTelemetryFields( name, marketplace, getManagedPluginNames(), ), }) } await gracefulShutdown(0) } catch (error) { handlePluginCommandError(error, 'update', plugin) } }