Squash the imported source snapshot and follow-up documentation history into one root commit so the archive starts from a single coherent state. Constraint: Repository intentionally tracks an analyzed Claude Code source snapshot Constraint: Author and committer must be instructkr <no-contact@instruct.kr> Rejected: Preserve the four-step import/docs history | user explicitly requested one squashed commit Confidence: high Scope-risk: broad Reversibility: clean Directive: Keep future analysis and refactor commits separate from this archive baseline Tested: git status clean; local history rewritten to one commit; force-pushed main to origin and instructkr Not-tested: Fresh clone verification after push
879 lines
30 KiB
TypeScript
879 lines
30 KiB
TypeScript
/**
|
||
* Plugin and marketplace subcommand handlers — extracted from main.tsx for lazy loading.
|
||
* These are dynamically imported only when `claude plugin *` or `claude plugin marketplace *` runs.
|
||
*/
|
||
/* eslint-disable custom-rules/no-process-exit -- CLI subcommand handlers intentionally exit */
|
||
import figures from 'figures'
|
||
import { basename, dirname } from 'path'
|
||
import { setUseCoworkPlugins } from '../../bootstrap/state.js'
|
||
import {
|
||
type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
||
type AnalyticsMetadata_I_VERIFIED_THIS_IS_PII_TAGGED,
|
||
logEvent,
|
||
} from '../../services/analytics/index.js'
|
||
import {
|
||
disableAllPlugins,
|
||
disablePlugin,
|
||
enablePlugin,
|
||
installPlugin,
|
||
uninstallPlugin,
|
||
updatePluginCli,
|
||
VALID_INSTALLABLE_SCOPES,
|
||
VALID_UPDATE_SCOPES,
|
||
} from '../../services/plugins/pluginCliCommands.js'
|
||
import { getPluginErrorMessage } from '../../types/plugin.js'
|
||
import { errorMessage } from '../../utils/errors.js'
|
||
import { logError } from '../../utils/log.js'
|
||
import { clearAllCaches } from '../../utils/plugins/cacheUtils.js'
|
||
import { getInstallCounts } from '../../utils/plugins/installCounts.js'
|
||
import {
|
||
isPluginInstalled,
|
||
loadInstalledPluginsV2,
|
||
} from '../../utils/plugins/installedPluginsManager.js'
|
||
import {
|
||
createPluginId,
|
||
loadMarketplacesWithGracefulDegradation,
|
||
} from '../../utils/plugins/marketplaceHelpers.js'
|
||
import {
|
||
addMarketplaceSource,
|
||
loadKnownMarketplacesConfig,
|
||
refreshAllMarketplaces,
|
||
refreshMarketplace,
|
||
removeMarketplaceSource,
|
||
saveMarketplaceToSettings,
|
||
} from '../../utils/plugins/marketplaceManager.js'
|
||
import { loadPluginMcpServers } from '../../utils/plugins/mcpPluginIntegration.js'
|
||
import { parseMarketplaceInput } from '../../utils/plugins/parseMarketplaceInput.js'
|
||
import {
|
||
parsePluginIdentifier,
|
||
scopeToSettingSource,
|
||
} from '../../utils/plugins/pluginIdentifier.js'
|
||
import { loadAllPlugins } from '../../utils/plugins/pluginLoader.js'
|
||
import type { PluginSource } from '../../utils/plugins/schemas.js'
|
||
import {
|
||
type ValidationResult,
|
||
validateManifest,
|
||
validatePluginContents,
|
||
} from '../../utils/plugins/validatePlugin.js'
|
||
import { jsonStringify } from '../../utils/slowOperations.js'
|
||
import { plural } from '../../utils/stringUtils.js'
|
||
import { cliError, cliOk } from '../exit.js'
|
||
|
||
// Re-export for main.tsx to reference in option definitions
|
||
export { VALID_INSTALLABLE_SCOPES, VALID_UPDATE_SCOPES }
|
||
|
||
/**
|
||
* Helper function to handle marketplace command errors consistently.
|
||
*/
|
||
export function handleMarketplaceError(error: unknown, action: string): never {
|
||
logError(error)
|
||
cliError(`${figures.cross} Failed to ${action}: ${errorMessage(error)}`)
|
||
}
|
||
|
||
function printValidationResult(result: ValidationResult): void {
|
||
if (result.errors.length > 0) {
|
||
// biome-ignore lint/suspicious/noConsole:: intentional console output
|
||
console.log(
|
||
`${figures.cross} Found ${result.errors.length} ${plural(result.errors.length, 'error')}:\n`,
|
||
)
|
||
result.errors.forEach(error => {
|
||
// biome-ignore lint/suspicious/noConsole:: intentional console output
|
||
console.log(` ${figures.pointer} ${error.path}: ${error.message}`)
|
||
})
|
||
// biome-ignore lint/suspicious/noConsole:: intentional console output
|
||
console.log('')
|
||
}
|
||
if (result.warnings.length > 0) {
|
||
// biome-ignore lint/suspicious/noConsole:: intentional console output
|
||
console.log(
|
||
`${figures.warning} Found ${result.warnings.length} ${plural(result.warnings.length, 'warning')}:\n`,
|
||
)
|
||
result.warnings.forEach(warning => {
|
||
// biome-ignore lint/suspicious/noConsole:: intentional console output
|
||
console.log(` ${figures.pointer} ${warning.path}: ${warning.message}`)
|
||
})
|
||
// biome-ignore lint/suspicious/noConsole:: intentional console output
|
||
console.log('')
|
||
}
|
||
}
|
||
|
||
// plugin validate
|
||
export async function pluginValidateHandler(
|
||
manifestPath: string,
|
||
options: { cowork?: boolean },
|
||
): Promise<void> {
|
||
if (options.cowork) setUseCoworkPlugins(true)
|
||
try {
|
||
const result = await validateManifest(manifestPath)
|
||
|
||
// biome-ignore lint/suspicious/noConsole:: intentional console output
|
||
console.log(`Validating ${result.fileType} manifest: ${result.filePath}\n`)
|
||
printValidationResult(result)
|
||
|
||
// If this is a plugin manifest located inside a .claude-plugin directory,
|
||
// also validate the plugin's content files (skills, agents, commands,
|
||
// hooks). Works whether the user passed a directory or the plugin.json
|
||
// path directly.
|
||
let contentResults: ValidationResult[] = []
|
||
if (result.fileType === 'plugin') {
|
||
const manifestDir = dirname(result.filePath)
|
||
if (basename(manifestDir) === '.claude-plugin') {
|
||
contentResults = await validatePluginContents(dirname(manifestDir))
|
||
for (const r of contentResults) {
|
||
// biome-ignore lint/suspicious/noConsole:: intentional console output
|
||
console.log(`Validating ${r.fileType}: ${r.filePath}\n`)
|
||
printValidationResult(r)
|
||
}
|
||
}
|
||
}
|
||
|
||
const allSuccess = result.success && contentResults.every(r => r.success)
|
||
const hasWarnings =
|
||
result.warnings.length > 0 ||
|
||
contentResults.some(r => r.warnings.length > 0)
|
||
|
||
if (allSuccess) {
|
||
cliOk(
|
||
hasWarnings
|
||
? `${figures.tick} Validation passed with warnings`
|
||
: `${figures.tick} Validation passed`,
|
||
)
|
||
} else {
|
||
// biome-ignore lint/suspicious/noConsole:: intentional console output
|
||
console.log(`${figures.cross} Validation failed`)
|
||
process.exit(1)
|
||
}
|
||
} catch (error) {
|
||
logError(error)
|
||
// biome-ignore lint/suspicious/noConsole:: intentional console output
|
||
console.error(
|
||
`${figures.cross} Unexpected error during validation: ${errorMessage(error)}`,
|
||
)
|
||
process.exit(2)
|
||
}
|
||
}
|
||
|
||
// plugin list (lines 5217–5416)
|
||
export async function pluginListHandler(options: {
|
||
json?: boolean
|
||
available?: boolean
|
||
cowork?: boolean
|
||
}): Promise<void> {
|
||
if (options.cowork) setUseCoworkPlugins(true)
|
||
logEvent('tengu_plugin_list_command', {})
|
||
|
||
const installedData = loadInstalledPluginsV2()
|
||
const { getPluginEditableScopes } = await import(
|
||
'../../utils/plugins/pluginStartupCheck.js'
|
||
)
|
||
const enabledPlugins = getPluginEditableScopes()
|
||
|
||
const pluginIds = Object.keys(installedData.plugins)
|
||
|
||
// Load all plugins once. The JSON and human paths both need:
|
||
// - loadErrors (to show load failures per plugin)
|
||
// - inline plugins (session-only via --plugin-dir, source='name@inline')
|
||
// which are NOT in installedData.plugins (V2 bookkeeping) — they must
|
||
// be surfaced separately or `plugin list` silently ignores --plugin-dir.
|
||
const {
|
||
enabled: loadedEnabled,
|
||
disabled: loadedDisabled,
|
||
errors: loadErrors,
|
||
} = await loadAllPlugins()
|
||
const allLoadedPlugins = [...loadedEnabled, ...loadedDisabled]
|
||
const inlinePlugins = allLoadedPlugins.filter(p =>
|
||
p.source.endsWith('@inline'),
|
||
)
|
||
// Path-level inline failures (dir doesn't exist, parse error before
|
||
// manifest is read) use source='inline[N]'. Plugin-level errors after
|
||
// manifest read use source='name@inline'. Collect both for the session
|
||
// section — these are otherwise invisible since they have no pluginId.
|
||
const inlineLoadErrors = loadErrors.filter(
|
||
e => e.source.endsWith('@inline') || e.source.startsWith('inline['),
|
||
)
|
||
|
||
if (options.json) {
|
||
// Create a map of plugin source to loaded plugin for quick lookup
|
||
const loadedPluginMap = new Map(allLoadedPlugins.map(p => [p.source, p]))
|
||
|
||
const plugins: Array<{
|
||
id: string
|
||
version: string
|
||
scope: string
|
||
enabled: boolean
|
||
installPath: string
|
||
installedAt?: string
|
||
lastUpdated?: string
|
||
projectPath?: string
|
||
mcpServers?: Record<string, unknown>
|
||
errors?: string[]
|
||
}> = []
|
||
|
||
for (const pluginId of pluginIds.sort()) {
|
||
const installations = installedData.plugins[pluginId]
|
||
if (!installations || installations.length === 0) continue
|
||
|
||
// Find loading errors for this plugin
|
||
const pluginName = parsePluginIdentifier(pluginId).name
|
||
const pluginErrors = loadErrors
|
||
.filter(
|
||
e =>
|
||
e.source === pluginId || ('plugin' in e && e.plugin === pluginName),
|
||
)
|
||
.map(getPluginErrorMessage)
|
||
|
||
for (const installation of installations) {
|
||
// Try to find the loaded plugin to get MCP servers
|
||
const loadedPlugin = loadedPluginMap.get(pluginId)
|
||
let mcpServers: Record<string, unknown> | undefined
|
||
|
||
if (loadedPlugin) {
|
||
// Load MCP servers if not already cached
|
||
const servers =
|
||
loadedPlugin.mcpServers ||
|
||
(await loadPluginMcpServers(loadedPlugin))
|
||
if (servers && Object.keys(servers).length > 0) {
|
||
mcpServers = servers
|
||
}
|
||
}
|
||
|
||
plugins.push({
|
||
id: pluginId,
|
||
version: installation.version || 'unknown',
|
||
scope: installation.scope,
|
||
enabled: enabledPlugins.has(pluginId),
|
||
installPath: installation.installPath,
|
||
installedAt: installation.installedAt,
|
||
lastUpdated: installation.lastUpdated,
|
||
projectPath: installation.projectPath,
|
||
mcpServers,
|
||
errors: pluginErrors.length > 0 ? pluginErrors : undefined,
|
||
})
|
||
}
|
||
}
|
||
|
||
// Session-only plugins: scope='session', no install metadata.
|
||
// Filter from inlineLoadErrors (not loadErrors) so an installed plugin
|
||
// with the same manifest name doesn't cross-contaminate via e.plugin.
|
||
// The e.plugin fallback catches the dirName≠manifestName case:
|
||
// createPluginFromPath tags errors with `${dirName}@inline` but
|
||
// plugin.source is reassigned to `${manifest.name}@inline` afterward
|
||
// (pluginLoader.ts loadInlinePlugins), so e.source !== p.source when
|
||
// a dev checkout dir like ~/code/my-fork/ has manifest name 'cool-plugin'.
|
||
for (const p of inlinePlugins) {
|
||
const servers = p.mcpServers || (await loadPluginMcpServers(p))
|
||
const pErrors = inlineLoadErrors
|
||
.filter(
|
||
e => e.source === p.source || ('plugin' in e && e.plugin === p.name),
|
||
)
|
||
.map(getPluginErrorMessage)
|
||
plugins.push({
|
||
id: p.source,
|
||
version: p.manifest.version ?? 'unknown',
|
||
scope: 'session',
|
||
enabled: p.enabled !== false,
|
||
installPath: p.path,
|
||
mcpServers:
|
||
servers && Object.keys(servers).length > 0 ? servers : undefined,
|
||
errors: pErrors.length > 0 ? pErrors : undefined,
|
||
})
|
||
}
|
||
// Path-level inline failures (--plugin-dir /nonexistent): no LoadedPlugin
|
||
// exists so the loop above can't surface them. Mirror the human-path
|
||
// handling so JSON consumers see the failure instead of silent omission.
|
||
for (const e of inlineLoadErrors.filter(e =>
|
||
e.source.startsWith('inline['),
|
||
)) {
|
||
plugins.push({
|
||
id: e.source,
|
||
version: 'unknown',
|
||
scope: 'session',
|
||
enabled: false,
|
||
installPath: 'path' in e ? e.path : '',
|
||
errors: [getPluginErrorMessage(e)],
|
||
})
|
||
}
|
||
|
||
// If --available is set, also load available plugins from marketplaces
|
||
if (options.available) {
|
||
const available: Array<{
|
||
pluginId: string
|
||
name: string
|
||
description?: string
|
||
marketplaceName: string
|
||
version?: string
|
||
source: PluginSource
|
||
installCount?: number
|
||
}> = []
|
||
|
||
try {
|
||
const [config, installCounts] = await Promise.all([
|
||
loadKnownMarketplacesConfig(),
|
||
getInstallCounts(),
|
||
])
|
||
const { marketplaces } =
|
||
await loadMarketplacesWithGracefulDegradation(config)
|
||
|
||
for (const {
|
||
name: marketplaceName,
|
||
data: marketplace,
|
||
} of marketplaces) {
|
||
if (marketplace) {
|
||
for (const entry of marketplace.plugins) {
|
||
const pluginId = createPluginId(entry.name, marketplaceName)
|
||
// Only include plugins that are not already installed
|
||
if (!isPluginInstalled(pluginId)) {
|
||
available.push({
|
||
pluginId,
|
||
name: entry.name,
|
||
description: entry.description,
|
||
marketplaceName,
|
||
version: entry.version,
|
||
source: entry.source,
|
||
installCount: installCounts?.get(pluginId),
|
||
})
|
||
}
|
||
}
|
||
}
|
||
}
|
||
} catch {
|
||
// Silently ignore marketplace loading errors
|
||
}
|
||
|
||
cliOk(jsonStringify({ installed: plugins, available }, null, 2))
|
||
} else {
|
||
cliOk(jsonStringify(plugins, null, 2))
|
||
}
|
||
}
|
||
|
||
if (pluginIds.length === 0 && inlinePlugins.length === 0) {
|
||
// inlineLoadErrors can exist with zero inline plugins (e.g. --plugin-dir
|
||
// points at a nonexistent path). Don't early-exit over them — fall
|
||
// through to the session section so the failure is visible.
|
||
if (inlineLoadErrors.length === 0) {
|
||
cliOk(
|
||
'No plugins installed. Use `claude plugin install` to install a plugin.',
|
||
)
|
||
}
|
||
}
|
||
|
||
if (pluginIds.length > 0) {
|
||
// biome-ignore lint/suspicious/noConsole:: intentional console output
|
||
console.log('Installed plugins:\n')
|
||
}
|
||
|
||
for (const pluginId of pluginIds.sort()) {
|
||
const installations = installedData.plugins[pluginId]
|
||
if (!installations || installations.length === 0) continue
|
||
|
||
// Find loading errors for this plugin
|
||
const pluginName = parsePluginIdentifier(pluginId).name
|
||
const pluginErrors = loadErrors.filter(
|
||
e => e.source === pluginId || ('plugin' in e && e.plugin === pluginName),
|
||
)
|
||
|
||
for (const installation of installations) {
|
||
const isEnabled = enabledPlugins.has(pluginId)
|
||
const status =
|
||
pluginErrors.length > 0
|
||
? `${figures.cross} failed to load`
|
||
: isEnabled
|
||
? `${figures.tick} enabled`
|
||
: `${figures.cross} disabled`
|
||
const version = installation.version || 'unknown'
|
||
const scope = installation.scope
|
||
|
||
// biome-ignore lint/suspicious/noConsole:: intentional console output
|
||
console.log(` ${figures.pointer} ${pluginId}`)
|
||
// biome-ignore lint/suspicious/noConsole:: intentional console output
|
||
console.log(` Version: ${version}`)
|
||
// biome-ignore lint/suspicious/noConsole:: intentional console output
|
||
console.log(` Scope: ${scope}`)
|
||
// biome-ignore lint/suspicious/noConsole:: intentional console output
|
||
console.log(` Status: ${status}`)
|
||
for (const error of pluginErrors) {
|
||
// biome-ignore lint/suspicious/noConsole:: intentional console output
|
||
console.log(` Error: ${getPluginErrorMessage(error)}`)
|
||
}
|
||
// biome-ignore lint/suspicious/noConsole:: intentional console output
|
||
console.log('')
|
||
}
|
||
}
|
||
|
||
if (inlinePlugins.length > 0 || inlineLoadErrors.length > 0) {
|
||
// biome-ignore lint/suspicious/noConsole:: intentional console output
|
||
console.log('Session-only plugins (--plugin-dir):\n')
|
||
for (const p of inlinePlugins) {
|
||
// Same dirName≠manifestName fallback as the JSON path above — error
|
||
// sources use the dir basename but p.source uses the manifest name.
|
||
const pErrors = inlineLoadErrors.filter(
|
||
e => e.source === p.source || ('plugin' in e && e.plugin === p.name),
|
||
)
|
||
const status =
|
||
pErrors.length > 0
|
||
? `${figures.cross} loaded with errors`
|
||
: `${figures.tick} loaded`
|
||
// biome-ignore lint/suspicious/noConsole:: intentional console output
|
||
console.log(` ${figures.pointer} ${p.source}`)
|
||
// biome-ignore lint/suspicious/noConsole:: intentional console output
|
||
console.log(` Version: ${p.manifest.version ?? 'unknown'}`)
|
||
// biome-ignore lint/suspicious/noConsole:: intentional console output
|
||
console.log(` Path: ${p.path}`)
|
||
// biome-ignore lint/suspicious/noConsole:: intentional console output
|
||
console.log(` Status: ${status}`)
|
||
for (const e of pErrors) {
|
||
// biome-ignore lint/suspicious/noConsole:: intentional console output
|
||
console.log(` Error: ${getPluginErrorMessage(e)}`)
|
||
}
|
||
// biome-ignore lint/suspicious/noConsole:: intentional console output
|
||
console.log('')
|
||
}
|
||
// Path-level failures: no LoadedPlugin object exists. Show them so
|
||
// `--plugin-dir /typo` doesn't just silently produce nothing.
|
||
for (const e of inlineLoadErrors.filter(e =>
|
||
e.source.startsWith('inline['),
|
||
)) {
|
||
// biome-ignore lint/suspicious/noConsole:: intentional console output
|
||
console.log(
|
||
` ${figures.pointer} ${e.source}: ${figures.cross} ${getPluginErrorMessage(e)}\n`,
|
||
)
|
||
}
|
||
}
|
||
|
||
cliOk()
|
||
}
|
||
|
||
// marketplace add (lines 5433–5487)
|
||
export async function marketplaceAddHandler(
|
||
source: string,
|
||
options: { cowork?: boolean; sparse?: string[]; scope?: string },
|
||
): Promise<void> {
|
||
if (options.cowork) setUseCoworkPlugins(true)
|
||
try {
|
||
const parsed = await parseMarketplaceInput(source)
|
||
|
||
if (!parsed) {
|
||
cliError(
|
||
`${figures.cross} Invalid marketplace source format. Try: owner/repo, https://..., or ./path`,
|
||
)
|
||
}
|
||
|
||
if ('error' in parsed) {
|
||
cliError(`${figures.cross} ${parsed.error}`)
|
||
}
|
||
|
||
// Validate scope
|
||
const scope = options.scope ?? 'user'
|
||
if (scope !== 'user' && scope !== 'project' && scope !== 'local') {
|
||
cliError(
|
||
`${figures.cross} Invalid scope '${scope}'. Use: user, project, or local`,
|
||
)
|
||
}
|
||
const settingSource = scopeToSettingSource(scope)
|
||
|
||
let marketplaceSource = parsed
|
||
|
||
if (options.sparse && options.sparse.length > 0) {
|
||
if (
|
||
marketplaceSource.source === 'github' ||
|
||
marketplaceSource.source === 'git'
|
||
) {
|
||
marketplaceSource = {
|
||
...marketplaceSource,
|
||
sparsePaths: options.sparse,
|
||
}
|
||
} else {
|
||
cliError(
|
||
`${figures.cross} --sparse is only supported for github and git marketplace sources (got: ${marketplaceSource.source})`,
|
||
)
|
||
}
|
||
}
|
||
|
||
// biome-ignore lint/suspicious/noConsole:: intentional console output
|
||
console.log('Adding marketplace...')
|
||
|
||
const { name, alreadyMaterialized, resolvedSource } =
|
||
await addMarketplaceSource(marketplaceSource, message => {
|
||
// biome-ignore lint/suspicious/noConsole:: intentional console output
|
||
console.log(message)
|
||
})
|
||
|
||
// Write intent to settings at the requested scope
|
||
saveMarketplaceToSettings(name, { source: resolvedSource }, settingSource)
|
||
|
||
clearAllCaches()
|
||
|
||
let sourceType = marketplaceSource.source
|
||
if (marketplaceSource.source === 'github') {
|
||
sourceType =
|
||
marketplaceSource.repo as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS
|
||
}
|
||
logEvent('tengu_marketplace_added', {
|
||
source_type:
|
||
sourceType as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
||
})
|
||
|
||
cliOk(
|
||
alreadyMaterialized
|
||
? `${figures.tick} Marketplace '${name}' already on disk — declared in ${scope} settings`
|
||
: `${figures.tick} Successfully added marketplace: ${name} (declared in ${scope} settings)`,
|
||
)
|
||
} catch (error) {
|
||
handleMarketplaceError(error, 'add marketplace')
|
||
}
|
||
}
|
||
|
||
// marketplace list (lines 5497–5565)
|
||
export async function marketplaceListHandler(options: {
|
||
json?: boolean
|
||
cowork?: boolean
|
||
}): Promise<void> {
|
||
if (options.cowork) setUseCoworkPlugins(true)
|
||
try {
|
||
const config = await loadKnownMarketplacesConfig()
|
||
const names = Object.keys(config)
|
||
|
||
if (options.json) {
|
||
const marketplaces = names.sort().map(name => {
|
||
const marketplace = config[name]
|
||
const source = marketplace?.source
|
||
return {
|
||
name,
|
||
source: source?.source,
|
||
...(source?.source === 'github' && { repo: source.repo }),
|
||
...(source?.source === 'git' && { url: source.url }),
|
||
...(source?.source === 'url' && { url: source.url }),
|
||
...(source?.source === 'directory' && { path: source.path }),
|
||
...(source?.source === 'file' && { path: source.path }),
|
||
installLocation: marketplace?.installLocation,
|
||
}
|
||
})
|
||
cliOk(jsonStringify(marketplaces, null, 2))
|
||
}
|
||
|
||
if (names.length === 0) {
|
||
cliOk('No marketplaces configured')
|
||
}
|
||
|
||
// biome-ignore lint/suspicious/noConsole:: intentional console output
|
||
console.log('Configured marketplaces:\n')
|
||
names.forEach(name => {
|
||
const marketplace = config[name]
|
||
// biome-ignore lint/suspicious/noConsole:: intentional console output
|
||
console.log(` ${figures.pointer} ${name}`)
|
||
|
||
if (marketplace?.source) {
|
||
const src = marketplace.source
|
||
if (src.source === 'github') {
|
||
// biome-ignore lint/suspicious/noConsole:: intentional console output
|
||
console.log(` Source: GitHub (${src.repo})`)
|
||
} else if (src.source === 'git') {
|
||
// biome-ignore lint/suspicious/noConsole:: intentional console output
|
||
console.log(` Source: Git (${src.url})`)
|
||
} else if (src.source === 'url') {
|
||
// biome-ignore lint/suspicious/noConsole:: intentional console output
|
||
console.log(` Source: URL (${src.url})`)
|
||
} else if (src.source === 'directory') {
|
||
// biome-ignore lint/suspicious/noConsole:: intentional console output
|
||
console.log(` Source: Directory (${src.path})`)
|
||
} else if (src.source === 'file') {
|
||
// biome-ignore lint/suspicious/noConsole:: intentional console output
|
||
console.log(` Source: File (${src.path})`)
|
||
}
|
||
}
|
||
// biome-ignore lint/suspicious/noConsole:: intentional console output
|
||
console.log('')
|
||
})
|
||
|
||
cliOk()
|
||
} catch (error) {
|
||
handleMarketplaceError(error, 'list marketplaces')
|
||
}
|
||
}
|
||
|
||
// marketplace remove (lines 5576–5598)
|
||
export async function marketplaceRemoveHandler(
|
||
name: string,
|
||
options: { cowork?: boolean },
|
||
): Promise<void> {
|
||
if (options.cowork) setUseCoworkPlugins(true)
|
||
try {
|
||
await removeMarketplaceSource(name)
|
||
clearAllCaches()
|
||
|
||
logEvent('tengu_marketplace_removed', {
|
||
marketplace_name:
|
||
name as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
||
})
|
||
|
||
cliOk(`${figures.tick} Successfully removed marketplace: ${name}`)
|
||
} catch (error) {
|
||
handleMarketplaceError(error, 'remove marketplace')
|
||
}
|
||
}
|
||
|
||
// marketplace update (lines 5609–5672)
|
||
export async function marketplaceUpdateHandler(
|
||
name: string | undefined,
|
||
options: { cowork?: boolean },
|
||
): Promise<void> {
|
||
if (options.cowork) setUseCoworkPlugins(true)
|
||
try {
|
||
if (name) {
|
||
// biome-ignore lint/suspicious/noConsole:: intentional console output
|
||
console.log(`Updating marketplace: ${name}...`)
|
||
|
||
await refreshMarketplace(name, message => {
|
||
// biome-ignore lint/suspicious/noConsole:: intentional console output
|
||
console.log(message)
|
||
})
|
||
|
||
clearAllCaches()
|
||
|
||
logEvent('tengu_marketplace_updated', {
|
||
marketplace_name:
|
||
name as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
||
})
|
||
|
||
cliOk(`${figures.tick} Successfully updated marketplace: ${name}`)
|
||
} else {
|
||
const config = await loadKnownMarketplacesConfig()
|
||
const marketplaceNames = Object.keys(config)
|
||
|
||
if (marketplaceNames.length === 0) {
|
||
cliOk('No marketplaces configured')
|
||
}
|
||
|
||
// biome-ignore lint/suspicious/noConsole:: intentional console output
|
||
console.log(`Updating ${marketplaceNames.length} marketplace(s)...`)
|
||
|
||
await refreshAllMarketplaces()
|
||
clearAllCaches()
|
||
|
||
logEvent('tengu_marketplace_updated_all', {
|
||
count:
|
||
marketplaceNames.length as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
||
})
|
||
|
||
cliOk(
|
||
`${figures.tick} Successfully updated ${marketplaceNames.length} marketplace(s)`,
|
||
)
|
||
}
|
||
} catch (error) {
|
||
handleMarketplaceError(error, 'update marketplace(s)')
|
||
}
|
||
}
|
||
|
||
// plugin install (lines 5690–5721)
|
||
export async function pluginInstallHandler(
|
||
plugin: string,
|
||
options: { scope?: string; cowork?: boolean },
|
||
): Promise<void> {
|
||
if (options.cowork) setUseCoworkPlugins(true)
|
||
const scope = options.scope || 'user'
|
||
if (options.cowork && scope !== 'user') {
|
||
cliError('--cowork can only be used with user scope')
|
||
}
|
||
if (
|
||
!VALID_INSTALLABLE_SCOPES.includes(
|
||
scope as (typeof VALID_INSTALLABLE_SCOPES)[number],
|
||
)
|
||
) {
|
||
cliError(
|
||
`Invalid scope: ${scope}. Must be one of: ${VALID_INSTALLABLE_SCOPES.join(', ')}.`,
|
||
)
|
||
}
|
||
// _PROTO_* routes to PII-tagged plugin_name/marketplace_name BQ columns.
|
||
// Unredacted plugin arg was previously logged to general-access
|
||
// additional_metadata for all users — dropped in favor of the privileged
|
||
// column route. marketplace may be undefined (fires before resolution).
|
||
const { name, marketplace } = parsePluginIdentifier(plugin)
|
||
logEvent('tengu_plugin_install_command', {
|
||
_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: scope as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
||
})
|
||
|
||
await installPlugin(plugin, scope as 'user' | 'project' | 'local')
|
||
}
|
||
|
||
// plugin uninstall (lines 5738–5769)
|
||
export async function pluginUninstallHandler(
|
||
plugin: string,
|
||
options: { scope?: string; cowork?: boolean; keepData?: boolean },
|
||
): Promise<void> {
|
||
if (options.cowork) setUseCoworkPlugins(true)
|
||
const scope = options.scope || 'user'
|
||
if (options.cowork && scope !== 'user') {
|
||
cliError('--cowork can only be used with user scope')
|
||
}
|
||
if (
|
||
!VALID_INSTALLABLE_SCOPES.includes(
|
||
scope as (typeof VALID_INSTALLABLE_SCOPES)[number],
|
||
)
|
||
) {
|
||
cliError(
|
||
`Invalid scope: ${scope}. Must be one of: ${VALID_INSTALLABLE_SCOPES.join(', ')}.`,
|
||
)
|
||
}
|
||
const { name, marketplace } = parsePluginIdentifier(plugin)
|
||
logEvent('tengu_plugin_uninstall_command', {
|
||
_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: scope as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
||
})
|
||
|
||
await uninstallPlugin(
|
||
plugin,
|
||
scope as 'user' | 'project' | 'local',
|
||
options.keepData,
|
||
)
|
||
}
|
||
|
||
// plugin enable (lines 5783–5818)
|
||
export async function pluginEnableHandler(
|
||
plugin: string,
|
||
options: { scope?: string; cowork?: boolean },
|
||
): Promise<void> {
|
||
if (options.cowork) setUseCoworkPlugins(true)
|
||
let scope: (typeof VALID_INSTALLABLE_SCOPES)[number] | undefined
|
||
if (options.scope) {
|
||
if (
|
||
!VALID_INSTALLABLE_SCOPES.includes(
|
||
options.scope as (typeof VALID_INSTALLABLE_SCOPES)[number],
|
||
)
|
||
) {
|
||
cliError(
|
||
`Invalid scope "${options.scope}". Valid scopes: ${VALID_INSTALLABLE_SCOPES.join(', ')}`,
|
||
)
|
||
}
|
||
scope = options.scope as (typeof VALID_INSTALLABLE_SCOPES)[number]
|
||
}
|
||
if (options.cowork && scope !== undefined && scope !== 'user') {
|
||
cliError('--cowork can only be used with user scope')
|
||
}
|
||
|
||
// --cowork always operates at user scope
|
||
if (options.cowork && scope === undefined) {
|
||
scope = 'user'
|
||
}
|
||
|
||
const { name, marketplace } = parsePluginIdentifier(plugin)
|
||
logEvent('tengu_plugin_enable_command', {
|
||
_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: (scope ??
|
||
'auto') as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
||
})
|
||
|
||
await enablePlugin(plugin, scope)
|
||
}
|
||
|
||
// plugin disable (lines 5833–5902)
|
||
export async function pluginDisableHandler(
|
||
plugin: string | undefined,
|
||
options: { scope?: string; cowork?: boolean; all?: boolean },
|
||
): Promise<void> {
|
||
if (options.all && plugin) {
|
||
cliError('Cannot use --all with a specific plugin')
|
||
}
|
||
|
||
if (!options.all && !plugin) {
|
||
cliError('Please specify a plugin name or use --all to disable all plugins')
|
||
}
|
||
|
||
if (options.cowork) setUseCoworkPlugins(true)
|
||
|
||
if (options.all) {
|
||
if (options.scope) {
|
||
cliError('Cannot use --scope with --all')
|
||
}
|
||
|
||
// No _PROTO_plugin_name here — --all disables all plugins.
|
||
// Distinguishable from the specific-plugin branch by plugin_name IS NULL.
|
||
logEvent('tengu_plugin_disable_command', {})
|
||
|
||
await disableAllPlugins()
|
||
return
|
||
}
|
||
|
||
let scope: (typeof VALID_INSTALLABLE_SCOPES)[number] | undefined
|
||
if (options.scope) {
|
||
if (
|
||
!VALID_INSTALLABLE_SCOPES.includes(
|
||
options.scope as (typeof VALID_INSTALLABLE_SCOPES)[number],
|
||
)
|
||
) {
|
||
cliError(
|
||
`Invalid scope "${options.scope}". Valid scopes: ${VALID_INSTALLABLE_SCOPES.join(', ')}`,
|
||
)
|
||
}
|
||
scope = options.scope as (typeof VALID_INSTALLABLE_SCOPES)[number]
|
||
}
|
||
if (options.cowork && scope !== undefined && scope !== 'user') {
|
||
cliError('--cowork can only be used with user scope')
|
||
}
|
||
|
||
// --cowork always operates at user scope
|
||
if (options.cowork && scope === undefined) {
|
||
scope = 'user'
|
||
}
|
||
|
||
const { name, marketplace } = parsePluginIdentifier(plugin!)
|
||
logEvent('tengu_plugin_disable_command', {
|
||
_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: (scope ??
|
||
'auto') as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
||
})
|
||
|
||
await disablePlugin(plugin!, scope)
|
||
}
|
||
|
||
// plugin update (lines 5918–5948)
|
||
export async function pluginUpdateHandler(
|
||
plugin: string,
|
||
options: { scope?: string; cowork?: boolean },
|
||
): Promise<void> {
|
||
if (options.cowork) setUseCoworkPlugins(true)
|
||
const { name, marketplace } = parsePluginIdentifier(plugin)
|
||
logEvent('tengu_plugin_update_command', {
|
||
_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,
|
||
}),
|
||
})
|
||
|
||
let scope: (typeof VALID_UPDATE_SCOPES)[number] = 'user'
|
||
if (options.scope) {
|
||
if (
|
||
!VALID_UPDATE_SCOPES.includes(
|
||
options.scope as (typeof VALID_UPDATE_SCOPES)[number],
|
||
)
|
||
) {
|
||
cliError(
|
||
`Invalid scope "${options.scope}". Valid scopes: ${VALID_UPDATE_SCOPES.join(', ')}`,
|
||
)
|
||
}
|
||
scope = options.scope as (typeof VALID_UPDATE_SCOPES)[number]
|
||
}
|
||
if (options.cowork && scope !== 'user') {
|
||
cliError('--cowork can only be used with user scope')
|
||
}
|
||
|
||
await updatePluginCli(plugin, scope)
|
||
}
|