source dump of claude code
at main 878 lines 31 kB view raw
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 */ 6import figures from 'figures' 7import { basename, dirname } from 'path' 8import { setUseCoworkPlugins } from '../../bootstrap/state.js' 9import { 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' 14import { 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' 24import { getPluginErrorMessage } from '../../types/plugin.js' 25import { errorMessage } from '../../utils/errors.js' 26import { logError } from '../../utils/log.js' 27import { clearAllCaches } from '../../utils/plugins/cacheUtils.js' 28import { getInstallCounts } from '../../utils/plugins/installCounts.js' 29import { 30 isPluginInstalled, 31 loadInstalledPluginsV2, 32} from '../../utils/plugins/installedPluginsManager.js' 33import { 34 createPluginId, 35 loadMarketplacesWithGracefulDegradation, 36} from '../../utils/plugins/marketplaceHelpers.js' 37import { 38 addMarketplaceSource, 39 loadKnownMarketplacesConfig, 40 refreshAllMarketplaces, 41 refreshMarketplace, 42 removeMarketplaceSource, 43 saveMarketplaceToSettings, 44} from '../../utils/plugins/marketplaceManager.js' 45import { loadPluginMcpServers } from '../../utils/plugins/mcpPluginIntegration.js' 46import { parseMarketplaceInput } from '../../utils/plugins/parseMarketplaceInput.js' 47import { 48 parsePluginIdentifier, 49 scopeToSettingSource, 50} from '../../utils/plugins/pluginIdentifier.js' 51import { loadAllPlugins } from '../../utils/plugins/pluginLoader.js' 52import type { PluginSource } from '../../utils/plugins/schemas.js' 53import { 54 type ValidationResult, 55 validateManifest, 56 validatePluginContents, 57} from '../../utils/plugins/validatePlugin.js' 58import { jsonStringify } from '../../utils/slowOperations.js' 59import { plural } from '../../utils/stringUtils.js' 60import { cliError, cliOk } from '../exit.js' 61 62// Re-export for main.tsx to reference in option definitions 63export { VALID_INSTALLABLE_SCOPES, VALID_UPDATE_SCOPES } 64 65/** 66 * Helper function to handle marketplace command errors consistently. 67 */ 68export function handleMarketplaceError(error: unknown, action: string): never { 69 logError(error) 70 cliError(`${figures.cross} Failed to ${action}: ${errorMessage(error)}`) 71} 72 73function 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 101export 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) 157export 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) 447export 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) 527export 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) 595export 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) 616export 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) 668export 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) 704export 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) 740export 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) 782export 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) 846export 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}