/ cli / handlers / plugins.ts
plugins.ts
  1  /**
  2   * Plugin and marketplace subcommand handlers — extracted from main.tsx for lazy loading.
  3   * These are dynamically imported only when `claude plugin *` or `claude plugin marketplace *` runs.
  4   */
  5  /* eslint-disable custom-rules/no-process-exit -- CLI subcommand handlers intentionally exit */
  6  import figures from 'figures'
  7  import { basename, dirname } from 'path'
  8  import { setUseCoworkPlugins } from '../../bootstrap/state.js'
  9  import {
 10    type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
 11    type AnalyticsMetadata_I_VERIFIED_THIS_IS_PII_TAGGED,
 12    logEvent,
 13  } from '../../services/analytics/index.js'
 14  import {
 15    disableAllPlugins,
 16    disablePlugin,
 17    enablePlugin,
 18    installPlugin,
 19    uninstallPlugin,
 20    updatePluginCli,
 21    VALID_INSTALLABLE_SCOPES,
 22    VALID_UPDATE_SCOPES,
 23  } from '../../services/plugins/pluginCliCommands.js'
 24  import { getPluginErrorMessage } from '../../types/plugin.js'
 25  import { errorMessage } from '../../utils/errors.js'
 26  import { logError } from '../../utils/log.js'
 27  import { clearAllCaches } from '../../utils/plugins/cacheUtils.js'
 28  import { getInstallCounts } from '../../utils/plugins/installCounts.js'
 29  import {
 30    isPluginInstalled,
 31    loadInstalledPluginsV2,
 32  } from '../../utils/plugins/installedPluginsManager.js'
 33  import {
 34    createPluginId,
 35    loadMarketplacesWithGracefulDegradation,
 36  } from '../../utils/plugins/marketplaceHelpers.js'
 37  import {
 38    addMarketplaceSource,
 39    loadKnownMarketplacesConfig,
 40    refreshAllMarketplaces,
 41    refreshMarketplace,
 42    removeMarketplaceSource,
 43    saveMarketplaceToSettings,
 44  } from '../../utils/plugins/marketplaceManager.js'
 45  import { loadPluginMcpServers } from '../../utils/plugins/mcpPluginIntegration.js'
 46  import { parseMarketplaceInput } from '../../utils/plugins/parseMarketplaceInput.js'
 47  import {
 48    parsePluginIdentifier,
 49    scopeToSettingSource,
 50  } from '../../utils/plugins/pluginIdentifier.js'
 51  import { loadAllPlugins } from '../../utils/plugins/pluginLoader.js'
 52  import type { PluginSource } from '../../utils/plugins/schemas.js'
 53  import {
 54    type ValidationResult,
 55    validateManifest,
 56    validatePluginContents,
 57  } from '../../utils/plugins/validatePlugin.js'
 58  import { jsonStringify } from '../../utils/slowOperations.js'
 59  import { plural } from '../../utils/stringUtils.js'
 60  import { cliError, cliOk } from '../exit.js'
 61  
 62  // Re-export for main.tsx to reference in option definitions
 63  export { VALID_INSTALLABLE_SCOPES, VALID_UPDATE_SCOPES }
 64  
 65  /**
 66   * Helper function to handle marketplace command errors consistently.
 67   */
 68  export function handleMarketplaceError(error: unknown, action: string): never {
 69    logError(error)
 70    cliError(`${figures.cross} Failed to ${action}: ${errorMessage(error)}`)
 71  }
 72  
 73  function printValidationResult(result: ValidationResult): void {
 74    if (result.errors.length > 0) {
 75      // biome-ignore lint/suspicious/noConsole:: intentional console output
 76      console.log(
 77        `${figures.cross} Found ${result.errors.length} ${plural(result.errors.length, 'error')}:\n`,
 78      )
 79      result.errors.forEach(error => {
 80        // biome-ignore lint/suspicious/noConsole:: intentional console output
 81        console.log(`  ${figures.pointer} ${error.path}: ${error.message}`)
 82      })
 83      // biome-ignore lint/suspicious/noConsole:: intentional console output
 84      console.log('')
 85    }
 86    if (result.warnings.length > 0) {
 87      // biome-ignore lint/suspicious/noConsole:: intentional console output
 88      console.log(
 89        `${figures.warning} Found ${result.warnings.length} ${plural(result.warnings.length, 'warning')}:\n`,
 90      )
 91      result.warnings.forEach(warning => {
 92        // biome-ignore lint/suspicious/noConsole:: intentional console output
 93        console.log(`  ${figures.pointer} ${warning.path}: ${warning.message}`)
 94      })
 95      // biome-ignore lint/suspicious/noConsole:: intentional console output
 96      console.log('')
 97    }
 98  }
 99  
100  // plugin validate
101  export async function pluginValidateHandler(
102    manifestPath: string,
103    options: { cowork?: boolean },
104  ): Promise<void> {
105    if (options.cowork) setUseCoworkPlugins(true)
106    try {
107      const result = await validateManifest(manifestPath)
108  
109      // biome-ignore lint/suspicious/noConsole:: intentional console output
110      console.log(`Validating ${result.fileType} manifest: ${result.filePath}\n`)
111      printValidationResult(result)
112  
113      // If this is a plugin manifest located inside a .claude-plugin directory,
114      // also validate the plugin's content files (skills, agents, commands,
115      // hooks). Works whether the user passed a directory or the plugin.json
116      // path directly.
117      let contentResults: ValidationResult[] = []
118      if (result.fileType === 'plugin') {
119        const manifestDir = dirname(result.filePath)
120        if (basename(manifestDir) === '.claude-plugin') {
121          contentResults = await validatePluginContents(dirname(manifestDir))
122          for (const r of contentResults) {
123            // biome-ignore lint/suspicious/noConsole:: intentional console output
124            console.log(`Validating ${r.fileType}: ${r.filePath}\n`)
125            printValidationResult(r)
126          }
127        }
128      }
129  
130      const allSuccess = result.success && contentResults.every(r => r.success)
131      const hasWarnings =
132        result.warnings.length > 0 ||
133        contentResults.some(r => r.warnings.length > 0)
134  
135      if (allSuccess) {
136        cliOk(
137          hasWarnings
138            ? `${figures.tick} Validation passed with warnings`
139            : `${figures.tick} Validation passed`,
140        )
141      } else {
142        // biome-ignore lint/suspicious/noConsole:: intentional console output
143        console.log(`${figures.cross} Validation failed`)
144        process.exit(1)
145      }
146    } catch (error) {
147      logError(error)
148      // biome-ignore lint/suspicious/noConsole:: intentional console output
149      console.error(
150        `${figures.cross} Unexpected error during validation: ${errorMessage(error)}`,
151      )
152      process.exit(2)
153    }
154  }
155  
156  // plugin list (lines 5217–5416)
157  export async function pluginListHandler(options: {
158    json?: boolean
159    available?: boolean
160    cowork?: boolean
161  }): Promise<void> {
162    if (options.cowork) setUseCoworkPlugins(true)
163    logEvent('tengu_plugin_list_command', {})
164  
165    const installedData = loadInstalledPluginsV2()
166    const { getPluginEditableScopes } = await import(
167      '../../utils/plugins/pluginStartupCheck.js'
168    )
169    const enabledPlugins = getPluginEditableScopes()
170  
171    const pluginIds = Object.keys(installedData.plugins)
172  
173    // Load all plugins once. The JSON and human paths both need:
174    //  - loadErrors (to show load failures per plugin)
175    //  - inline plugins (session-only via --plugin-dir, source='name@inline')
176    //    which are NOT in installedData.plugins (V2 bookkeeping) — they must
177    //    be surfaced separately or `plugin list` silently ignores --plugin-dir.
178    const {
179      enabled: loadedEnabled,
180      disabled: loadedDisabled,
181      errors: loadErrors,
182    } = await loadAllPlugins()
183    const allLoadedPlugins = [...loadedEnabled, ...loadedDisabled]
184    const inlinePlugins = allLoadedPlugins.filter(p =>
185      p.source.endsWith('@inline'),
186    )
187    // Path-level inline failures (dir doesn't exist, parse error before
188    // manifest is read) use source='inline[N]'. Plugin-level errors after
189    // manifest read use source='name@inline'. Collect both for the session
190    // section — these are otherwise invisible since they have no pluginId.
191    const inlineLoadErrors = loadErrors.filter(
192      e => e.source.endsWith('@inline') || e.source.startsWith('inline['),
193    )
194  
195    if (options.json) {
196      // Create a map of plugin source to loaded plugin for quick lookup
197      const loadedPluginMap = new Map(allLoadedPlugins.map(p => [p.source, p]))
198  
199      const plugins: Array<{
200        id: string
201        version: string
202        scope: string
203        enabled: boolean
204        installPath: string
205        installedAt?: string
206        lastUpdated?: string
207        projectPath?: string
208        mcpServers?: Record<string, unknown>
209        errors?: string[]
210      }> = []
211  
212      for (const pluginId of pluginIds.sort()) {
213        const installations = installedData.plugins[pluginId]
214        if (!installations || installations.length === 0) continue
215  
216        // Find loading errors for this plugin
217        const pluginName = parsePluginIdentifier(pluginId).name
218        const pluginErrors = loadErrors
219          .filter(
220            e =>
221              e.source === pluginId || ('plugin' in e && e.plugin === pluginName),
222          )
223          .map(getPluginErrorMessage)
224  
225        for (const installation of installations) {
226          // Try to find the loaded plugin to get MCP servers
227          const loadedPlugin = loadedPluginMap.get(pluginId)
228          let mcpServers: Record<string, unknown> | undefined
229  
230          if (loadedPlugin) {
231            // Load MCP servers if not already cached
232            const servers =
233              loadedPlugin.mcpServers ||
234              (await loadPluginMcpServers(loadedPlugin))
235            if (servers && Object.keys(servers).length > 0) {
236              mcpServers = servers
237            }
238          }
239  
240          plugins.push({
241            id: pluginId,
242            version: installation.version || 'unknown',
243            scope: installation.scope,
244            enabled: enabledPlugins.has(pluginId),
245            installPath: installation.installPath,
246            installedAt: installation.installedAt,
247            lastUpdated: installation.lastUpdated,
248            projectPath: installation.projectPath,
249            mcpServers,
250            errors: pluginErrors.length > 0 ? pluginErrors : undefined,
251          })
252        }
253      }
254  
255      // Session-only plugins: scope='session', no install metadata.
256      // Filter from inlineLoadErrors (not loadErrors) so an installed plugin
257      // with the same manifest name doesn't cross-contaminate via e.plugin.
258      // The e.plugin fallback catches the dirName≠manifestName case:
259      // createPluginFromPath tags errors with `${dirName}@inline` but
260      // plugin.source is reassigned to `${manifest.name}@inline` afterward
261      // (pluginLoader.ts loadInlinePlugins), so e.source !== p.source when
262      // a dev checkout dir like ~/code/my-fork/ has manifest name 'cool-plugin'.
263      for (const p of inlinePlugins) {
264        const servers = p.mcpServers || (await loadPluginMcpServers(p))
265        const pErrors = inlineLoadErrors
266          .filter(
267            e => e.source === p.source || ('plugin' in e && e.plugin === p.name),
268          )
269          .map(getPluginErrorMessage)
270        plugins.push({
271          id: p.source,
272          version: p.manifest.version ?? 'unknown',
273          scope: 'session',
274          enabled: p.enabled !== false,
275          installPath: p.path,
276          mcpServers:
277            servers && Object.keys(servers).length > 0 ? servers : undefined,
278          errors: pErrors.length > 0 ? pErrors : undefined,
279        })
280      }
281      // Path-level inline failures (--plugin-dir /nonexistent): no LoadedPlugin
282      // exists so the loop above can't surface them. Mirror the human-path
283      // handling so JSON consumers see the failure instead of silent omission.
284      for (const e of inlineLoadErrors.filter(e =>
285        e.source.startsWith('inline['),
286      )) {
287        plugins.push({
288          id: e.source,
289          version: 'unknown',
290          scope: 'session',
291          enabled: false,
292          installPath: 'path' in e ? e.path : '',
293          errors: [getPluginErrorMessage(e)],
294        })
295      }
296  
297      // If --available is set, also load available plugins from marketplaces
298      if (options.available) {
299        const available: Array<{
300          pluginId: string
301          name: string
302          description?: string
303          marketplaceName: string
304          version?: string
305          source: PluginSource
306          installCount?: number
307        }> = []
308  
309        try {
310          const [config, installCounts] = await Promise.all([
311            loadKnownMarketplacesConfig(),
312            getInstallCounts(),
313          ])
314          const { marketplaces } =
315            await loadMarketplacesWithGracefulDegradation(config)
316  
317          for (const {
318            name: marketplaceName,
319            data: marketplace,
320          } of marketplaces) {
321            if (marketplace) {
322              for (const entry of marketplace.plugins) {
323                const pluginId = createPluginId(entry.name, marketplaceName)
324                // Only include plugins that are not already installed
325                if (!isPluginInstalled(pluginId)) {
326                  available.push({
327                    pluginId,
328                    name: entry.name,
329                    description: entry.description,
330                    marketplaceName,
331                    version: entry.version,
332                    source: entry.source,
333                    installCount: installCounts?.get(pluginId),
334                  })
335                }
336              }
337            }
338          }
339        } catch {
340          // Silently ignore marketplace loading errors
341        }
342  
343        cliOk(jsonStringify({ installed: plugins, available }, null, 2))
344      } else {
345        cliOk(jsonStringify(plugins, null, 2))
346      }
347    }
348  
349    if (pluginIds.length === 0 && inlinePlugins.length === 0) {
350      // inlineLoadErrors can exist with zero inline plugins (e.g. --plugin-dir
351      // points at a nonexistent path). Don't early-exit over them — fall
352      // through to the session section so the failure is visible.
353      if (inlineLoadErrors.length === 0) {
354        cliOk(
355          'No plugins installed. Use `claude plugin install` to install a plugin.',
356        )
357      }
358    }
359  
360    if (pluginIds.length > 0) {
361      // biome-ignore lint/suspicious/noConsole:: intentional console output
362      console.log('Installed plugins:\n')
363    }
364  
365    for (const pluginId of pluginIds.sort()) {
366      const installations = installedData.plugins[pluginId]
367      if (!installations || installations.length === 0) continue
368  
369      // Find loading errors for this plugin
370      const pluginName = parsePluginIdentifier(pluginId).name
371      const pluginErrors = loadErrors.filter(
372        e => e.source === pluginId || ('plugin' in e && e.plugin === pluginName),
373      )
374  
375      for (const installation of installations) {
376        const isEnabled = enabledPlugins.has(pluginId)
377        const status =
378          pluginErrors.length > 0
379            ? `${figures.cross} failed to load`
380            : isEnabled
381              ? `${figures.tick} enabled`
382              : `${figures.cross} disabled`
383        const version = installation.version || 'unknown'
384        const scope = installation.scope
385  
386        // biome-ignore lint/suspicious/noConsole:: intentional console output
387        console.log(`  ${figures.pointer} ${pluginId}`)
388        // biome-ignore lint/suspicious/noConsole:: intentional console output
389        console.log(`    Version: ${version}`)
390        // biome-ignore lint/suspicious/noConsole:: intentional console output
391        console.log(`    Scope: ${scope}`)
392        // biome-ignore lint/suspicious/noConsole:: intentional console output
393        console.log(`    Status: ${status}`)
394        for (const error of pluginErrors) {
395          // biome-ignore lint/suspicious/noConsole:: intentional console output
396          console.log(`    Error: ${getPluginErrorMessage(error)}`)
397        }
398        // biome-ignore lint/suspicious/noConsole:: intentional console output
399        console.log('')
400      }
401    }
402  
403    if (inlinePlugins.length > 0 || inlineLoadErrors.length > 0) {
404      // biome-ignore lint/suspicious/noConsole:: intentional console output
405      console.log('Session-only plugins (--plugin-dir):\n')
406      for (const p of inlinePlugins) {
407        // Same dirName≠manifestName fallback as the JSON path above — error
408        // sources use the dir basename but p.source uses the manifest name.
409        const pErrors = inlineLoadErrors.filter(
410          e => e.source === p.source || ('plugin' in e && e.plugin === p.name),
411        )
412        const status =
413          pErrors.length > 0
414            ? `${figures.cross} loaded with errors`
415            : `${figures.tick} loaded`
416        // biome-ignore lint/suspicious/noConsole:: intentional console output
417        console.log(`  ${figures.pointer} ${p.source}`)
418        // biome-ignore lint/suspicious/noConsole:: intentional console output
419        console.log(`    Version: ${p.manifest.version ?? 'unknown'}`)
420        // biome-ignore lint/suspicious/noConsole:: intentional console output
421        console.log(`    Path: ${p.path}`)
422        // biome-ignore lint/suspicious/noConsole:: intentional console output
423        console.log(`    Status: ${status}`)
424        for (const e of pErrors) {
425          // biome-ignore lint/suspicious/noConsole:: intentional console output
426          console.log(`    Error: ${getPluginErrorMessage(e)}`)
427        }
428        // biome-ignore lint/suspicious/noConsole:: intentional console output
429        console.log('')
430      }
431      // Path-level failures: no LoadedPlugin object exists. Show them so
432      // `--plugin-dir /typo` doesn't just silently produce nothing.
433      for (const e of inlineLoadErrors.filter(e =>
434        e.source.startsWith('inline['),
435      )) {
436        // biome-ignore lint/suspicious/noConsole:: intentional console output
437        console.log(
438          `  ${figures.pointer} ${e.source}: ${figures.cross} ${getPluginErrorMessage(e)}\n`,
439        )
440      }
441    }
442  
443    cliOk()
444  }
445  
446  // marketplace add (lines 5433–5487)
447  export async function marketplaceAddHandler(
448    source: string,
449    options: { cowork?: boolean; sparse?: string[]; scope?: string },
450  ): Promise<void> {
451    if (options.cowork) setUseCoworkPlugins(true)
452    try {
453      const parsed = await parseMarketplaceInput(source)
454  
455      if (!parsed) {
456        cliError(
457          `${figures.cross} Invalid marketplace source format. Try: owner/repo, https://..., or ./path`,
458        )
459      }
460  
461      if ('error' in parsed) {
462        cliError(`${figures.cross} ${parsed.error}`)
463      }
464  
465      // Validate scope
466      const scope = options.scope ?? 'user'
467      if (scope !== 'user' && scope !== 'project' && scope !== 'local') {
468        cliError(
469          `${figures.cross} Invalid scope '${scope}'. Use: user, project, or local`,
470        )
471      }
472      const settingSource = scopeToSettingSource(scope)
473  
474      let marketplaceSource = parsed
475  
476      if (options.sparse && options.sparse.length > 0) {
477        if (
478          marketplaceSource.source === 'github' ||
479          marketplaceSource.source === 'git'
480        ) {
481          marketplaceSource = {
482            ...marketplaceSource,
483            sparsePaths: options.sparse,
484          }
485        } else {
486          cliError(
487            `${figures.cross} --sparse is only supported for github and git marketplace sources (got: ${marketplaceSource.source})`,
488          )
489        }
490      }
491  
492      // biome-ignore lint/suspicious/noConsole:: intentional console output
493      console.log('Adding marketplace...')
494  
495      const { name, alreadyMaterialized, resolvedSource } =
496        await addMarketplaceSource(marketplaceSource, message => {
497          // biome-ignore lint/suspicious/noConsole:: intentional console output
498          console.log(message)
499        })
500  
501      // Write intent to settings at the requested scope
502      saveMarketplaceToSettings(name, { source: resolvedSource }, settingSource)
503  
504      clearAllCaches()
505  
506      let sourceType = marketplaceSource.source
507      if (marketplaceSource.source === 'github') {
508        sourceType =
509          marketplaceSource.repo as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS
510      }
511      logEvent('tengu_marketplace_added', {
512        source_type:
513          sourceType as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
514      })
515  
516      cliOk(
517        alreadyMaterialized
518          ? `${figures.tick} Marketplace '${name}' already on disk — declared in ${scope} settings`
519          : `${figures.tick} Successfully added marketplace: ${name} (declared in ${scope} settings)`,
520      )
521    } catch (error) {
522      handleMarketplaceError(error, 'add marketplace')
523    }
524  }
525  
526  // marketplace list (lines 5497–5565)
527  export async function marketplaceListHandler(options: {
528    json?: boolean
529    cowork?: boolean
530  }): Promise<void> {
531    if (options.cowork) setUseCoworkPlugins(true)
532    try {
533      const config = await loadKnownMarketplacesConfig()
534      const names = Object.keys(config)
535  
536      if (options.json) {
537        const marketplaces = names.sort().map(name => {
538          const marketplace = config[name]
539          const source = marketplace?.source
540          return {
541            name,
542            source: source?.source,
543            ...(source?.source === 'github' && { repo: source.repo }),
544            ...(source?.source === 'git' && { url: source.url }),
545            ...(source?.source === 'url' && { url: source.url }),
546            ...(source?.source === 'directory' && { path: source.path }),
547            ...(source?.source === 'file' && { path: source.path }),
548            installLocation: marketplace?.installLocation,
549          }
550        })
551        cliOk(jsonStringify(marketplaces, null, 2))
552      }
553  
554      if (names.length === 0) {
555        cliOk('No marketplaces configured')
556      }
557  
558      // biome-ignore lint/suspicious/noConsole:: intentional console output
559      console.log('Configured marketplaces:\n')
560      names.forEach(name => {
561        const marketplace = config[name]
562        // biome-ignore lint/suspicious/noConsole:: intentional console output
563        console.log(`  ${figures.pointer} ${name}`)
564  
565        if (marketplace?.source) {
566          const src = marketplace.source
567          if (src.source === 'github') {
568            // biome-ignore lint/suspicious/noConsole:: intentional console output
569            console.log(`    Source: GitHub (${src.repo})`)
570          } else if (src.source === 'git') {
571            // biome-ignore lint/suspicious/noConsole:: intentional console output
572            console.log(`    Source: Git (${src.url})`)
573          } else if (src.source === 'url') {
574            // biome-ignore lint/suspicious/noConsole:: intentional console output
575            console.log(`    Source: URL (${src.url})`)
576          } else if (src.source === 'directory') {
577            // biome-ignore lint/suspicious/noConsole:: intentional console output
578            console.log(`    Source: Directory (${src.path})`)
579          } else if (src.source === 'file') {
580            // biome-ignore lint/suspicious/noConsole:: intentional console output
581            console.log(`    Source: File (${src.path})`)
582          }
583        }
584        // biome-ignore lint/suspicious/noConsole:: intentional console output
585        console.log('')
586      })
587  
588      cliOk()
589    } catch (error) {
590      handleMarketplaceError(error, 'list marketplaces')
591    }
592  }
593  
594  // marketplace remove (lines 5576–5598)
595  export async function marketplaceRemoveHandler(
596    name: string,
597    options: { cowork?: boolean },
598  ): Promise<void> {
599    if (options.cowork) setUseCoworkPlugins(true)
600    try {
601      await removeMarketplaceSource(name)
602      clearAllCaches()
603  
604      logEvent('tengu_marketplace_removed', {
605        marketplace_name:
606          name as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
607      })
608  
609      cliOk(`${figures.tick} Successfully removed marketplace: ${name}`)
610    } catch (error) {
611      handleMarketplaceError(error, 'remove marketplace')
612    }
613  }
614  
615  // marketplace update (lines 5609–5672)
616  export async function marketplaceUpdateHandler(
617    name: string | undefined,
618    options: { cowork?: boolean },
619  ): Promise<void> {
620    if (options.cowork) setUseCoworkPlugins(true)
621    try {
622      if (name) {
623        // biome-ignore lint/suspicious/noConsole:: intentional console output
624        console.log(`Updating marketplace: ${name}...`)
625  
626        await refreshMarketplace(name, message => {
627          // biome-ignore lint/suspicious/noConsole:: intentional console output
628          console.log(message)
629        })
630  
631        clearAllCaches()
632  
633        logEvent('tengu_marketplace_updated', {
634          marketplace_name:
635            name as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
636        })
637  
638        cliOk(`${figures.tick} Successfully updated marketplace: ${name}`)
639      } else {
640        const config = await loadKnownMarketplacesConfig()
641        const marketplaceNames = Object.keys(config)
642  
643        if (marketplaceNames.length === 0) {
644          cliOk('No marketplaces configured')
645        }
646  
647        // biome-ignore lint/suspicious/noConsole:: intentional console output
648        console.log(`Updating ${marketplaceNames.length} marketplace(s)...`)
649  
650        await refreshAllMarketplaces()
651        clearAllCaches()
652  
653        logEvent('tengu_marketplace_updated_all', {
654          count:
655            marketplaceNames.length as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
656        })
657  
658        cliOk(
659          `${figures.tick} Successfully updated ${marketplaceNames.length} marketplace(s)`,
660        )
661      }
662    } catch (error) {
663      handleMarketplaceError(error, 'update marketplace(s)')
664    }
665  }
666  
667  // plugin install (lines 5690–5721)
668  export async function pluginInstallHandler(
669    plugin: string,
670    options: { scope?: string; cowork?: boolean },
671  ): Promise<void> {
672    if (options.cowork) setUseCoworkPlugins(true)
673    const scope = options.scope || 'user'
674    if (options.cowork && scope !== 'user') {
675      cliError('--cowork can only be used with user scope')
676    }
677    if (
678      !VALID_INSTALLABLE_SCOPES.includes(
679        scope as (typeof VALID_INSTALLABLE_SCOPES)[number],
680      )
681    ) {
682      cliError(
683        `Invalid scope: ${scope}. Must be one of: ${VALID_INSTALLABLE_SCOPES.join(', ')}.`,
684      )
685    }
686    // _PROTO_* routes to PII-tagged plugin_name/marketplace_name BQ columns.
687    // Unredacted plugin arg was previously logged to general-access
688    // additional_metadata for all users — dropped in favor of the privileged
689    // column route. marketplace may be undefined (fires before resolution).
690    const { name, marketplace } = parsePluginIdentifier(plugin)
691    logEvent('tengu_plugin_install_command', {
692      _PROTO_plugin_name: name as AnalyticsMetadata_I_VERIFIED_THIS_IS_PII_TAGGED,
693      ...(marketplace && {
694        _PROTO_marketplace_name:
695          marketplace as AnalyticsMetadata_I_VERIFIED_THIS_IS_PII_TAGGED,
696      }),
697      scope: scope as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
698    })
699  
700    await installPlugin(plugin, scope as 'user' | 'project' | 'local')
701  }
702  
703  // plugin uninstall (lines 5738–5769)
704  export async function pluginUninstallHandler(
705    plugin: string,
706    options: { scope?: string; cowork?: boolean; keepData?: boolean },
707  ): Promise<void> {
708    if (options.cowork) setUseCoworkPlugins(true)
709    const scope = options.scope || 'user'
710    if (options.cowork && scope !== 'user') {
711      cliError('--cowork can only be used with user scope')
712    }
713    if (
714      !VALID_INSTALLABLE_SCOPES.includes(
715        scope as (typeof VALID_INSTALLABLE_SCOPES)[number],
716      )
717    ) {
718      cliError(
719        `Invalid scope: ${scope}. Must be one of: ${VALID_INSTALLABLE_SCOPES.join(', ')}.`,
720      )
721    }
722    const { name, marketplace } = parsePluginIdentifier(plugin)
723    logEvent('tengu_plugin_uninstall_command', {
724      _PROTO_plugin_name: name as AnalyticsMetadata_I_VERIFIED_THIS_IS_PII_TAGGED,
725      ...(marketplace && {
726        _PROTO_marketplace_name:
727          marketplace as AnalyticsMetadata_I_VERIFIED_THIS_IS_PII_TAGGED,
728      }),
729      scope: scope as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
730    })
731  
732    await uninstallPlugin(
733      plugin,
734      scope as 'user' | 'project' | 'local',
735      options.keepData,
736    )
737  }
738  
739  // plugin enable (lines 5783–5818)
740  export async function pluginEnableHandler(
741    plugin: string,
742    options: { scope?: string; cowork?: boolean },
743  ): Promise<void> {
744    if (options.cowork) setUseCoworkPlugins(true)
745    let scope: (typeof VALID_INSTALLABLE_SCOPES)[number] | undefined
746    if (options.scope) {
747      if (
748        !VALID_INSTALLABLE_SCOPES.includes(
749          options.scope as (typeof VALID_INSTALLABLE_SCOPES)[number],
750        )
751      ) {
752        cliError(
753          `Invalid scope "${options.scope}". Valid scopes: ${VALID_INSTALLABLE_SCOPES.join(', ')}`,
754        )
755      }
756      scope = options.scope as (typeof VALID_INSTALLABLE_SCOPES)[number]
757    }
758    if (options.cowork && scope !== undefined && scope !== 'user') {
759      cliError('--cowork can only be used with user scope')
760    }
761  
762    // --cowork always operates at user scope
763    if (options.cowork && scope === undefined) {
764      scope = 'user'
765    }
766  
767    const { name, marketplace } = parsePluginIdentifier(plugin)
768    logEvent('tengu_plugin_enable_command', {
769      _PROTO_plugin_name: name as AnalyticsMetadata_I_VERIFIED_THIS_IS_PII_TAGGED,
770      ...(marketplace && {
771        _PROTO_marketplace_name:
772          marketplace as AnalyticsMetadata_I_VERIFIED_THIS_IS_PII_TAGGED,
773      }),
774      scope: (scope ??
775        'auto') as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
776    })
777  
778    await enablePlugin(plugin, scope)
779  }
780  
781  // plugin disable (lines 5833–5902)
782  export async function pluginDisableHandler(
783    plugin: string | undefined,
784    options: { scope?: string; cowork?: boolean; all?: boolean },
785  ): Promise<void> {
786    if (options.all && plugin) {
787      cliError('Cannot use --all with a specific plugin')
788    }
789  
790    if (!options.all && !plugin) {
791      cliError('Please specify a plugin name or use --all to disable all plugins')
792    }
793  
794    if (options.cowork) setUseCoworkPlugins(true)
795  
796    if (options.all) {
797      if (options.scope) {
798        cliError('Cannot use --scope with --all')
799      }
800  
801      // No _PROTO_plugin_name here — --all disables all plugins.
802      // Distinguishable from the specific-plugin branch by plugin_name IS NULL.
803      logEvent('tengu_plugin_disable_command', {})
804  
805      await disableAllPlugins()
806      return
807    }
808  
809    let scope: (typeof VALID_INSTALLABLE_SCOPES)[number] | undefined
810    if (options.scope) {
811      if (
812        !VALID_INSTALLABLE_SCOPES.includes(
813          options.scope as (typeof VALID_INSTALLABLE_SCOPES)[number],
814        )
815      ) {
816        cliError(
817          `Invalid scope "${options.scope}". Valid scopes: ${VALID_INSTALLABLE_SCOPES.join(', ')}`,
818        )
819      }
820      scope = options.scope as (typeof VALID_INSTALLABLE_SCOPES)[number]
821    }
822    if (options.cowork && scope !== undefined && scope !== 'user') {
823      cliError('--cowork can only be used with user scope')
824    }
825  
826    // --cowork always operates at user scope
827    if (options.cowork && scope === undefined) {
828      scope = 'user'
829    }
830  
831    const { name, marketplace } = parsePluginIdentifier(plugin!)
832    logEvent('tengu_plugin_disable_command', {
833      _PROTO_plugin_name: name as AnalyticsMetadata_I_VERIFIED_THIS_IS_PII_TAGGED,
834      ...(marketplace && {
835        _PROTO_marketplace_name:
836          marketplace as AnalyticsMetadata_I_VERIFIED_THIS_IS_PII_TAGGED,
837      }),
838      scope: (scope ??
839        'auto') as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
840    })
841  
842    await disablePlugin(plugin!, scope)
843  }
844  
845  // plugin update (lines 5918–5948)
846  export async function pluginUpdateHandler(
847    plugin: string,
848    options: { scope?: string; cowork?: boolean },
849  ): Promise<void> {
850    if (options.cowork) setUseCoworkPlugins(true)
851    const { name, marketplace } = parsePluginIdentifier(plugin)
852    logEvent('tengu_plugin_update_command', {
853      _PROTO_plugin_name: name as AnalyticsMetadata_I_VERIFIED_THIS_IS_PII_TAGGED,
854      ...(marketplace && {
855        _PROTO_marketplace_name:
856          marketplace as AnalyticsMetadata_I_VERIFIED_THIS_IS_PII_TAGGED,
857      }),
858    })
859  
860    let scope: (typeof VALID_UPDATE_SCOPES)[number] = 'user'
861    if (options.scope) {
862      if (
863        !VALID_UPDATE_SCOPES.includes(
864          options.scope as (typeof VALID_UPDATE_SCOPES)[number],
865        )
866      ) {
867        cliError(
868          `Invalid scope "${options.scope}". Valid scopes: ${VALID_UPDATE_SCOPES.join(', ')}`,
869        )
870      }
871      scope = options.scope as (typeof VALID_UPDATE_SCOPES)[number]
872    }
873    if (options.cowork && scope !== 'user') {
874      cliError('--cowork can only be used with user scope')
875    }
876  
877    await updatePluginCli(plugin, scope)
878  }