init claude-code

This commit is contained in:
2026-04-01 17:32:37 +02:00
commit 73b208c009
1902 changed files with 513237 additions and 0 deletions
+70
View File
@@ -0,0 +1,70 @@
/**
* Agents subcommand handler — prints the list of configured agents.
* Dynamically imported only when `claude agents` runs.
*/
import {
AGENT_SOURCE_GROUPS,
compareAgentsByName,
getOverrideSourceLabel,
type ResolvedAgent,
resolveAgentModelDisplay,
resolveAgentOverrides,
} from '../../tools/AgentTool/agentDisplay.js'
import {
getActiveAgentsFromList,
getAgentDefinitionsWithOverrides,
} from '../../tools/AgentTool/loadAgentsDir.js'
import { getCwd } from '../../utils/cwd.js'
function formatAgent(agent: ResolvedAgent): string {
const model = resolveAgentModelDisplay(agent)
const parts = [agent.agentType]
if (model) {
parts.push(model)
}
if (agent.memory) {
parts.push(`${agent.memory} memory`)
}
return parts.join(' · ')
}
export async function agentsHandler(): Promise<void> {
const cwd = getCwd()
const { allAgents } = await getAgentDefinitionsWithOverrides(cwd)
const activeAgents = getActiveAgentsFromList(allAgents)
const resolvedAgents = resolveAgentOverrides(allAgents, activeAgents)
const lines: string[] = []
let totalActive = 0
for (const { label, source } of AGENT_SOURCE_GROUPS) {
const groupAgents = resolvedAgents
.filter(a => a.source === source)
.sort(compareAgentsByName)
if (groupAgents.length === 0) continue
lines.push(`${label}:`)
for (const agent of groupAgents) {
if (agent.overriddenBy) {
const winnerSource = getOverrideSourceLabel(agent.overriddenBy)
lines.push(` (shadowed by ${winnerSource}) ${formatAgent(agent)}`)
} else {
lines.push(` ${formatAgent(agent)}`)
totalActive++
}
}
lines.push('')
}
if (lines.length === 0) {
// biome-ignore lint/suspicious/noConsole:: intentional console output
console.log('No agents found.')
} else {
// biome-ignore lint/suspicious/noConsole:: intentional console output
console.log(`${totalActive} active agents\n`)
// biome-ignore lint/suspicious/noConsole:: intentional console output
console.log(lines.join('\n').trimEnd())
}
}
+330
View File
@@ -0,0 +1,330 @@
/* eslint-disable custom-rules/no-process-exit -- CLI subcommand handler intentionally exits */
import {
clearAuthRelatedCaches,
performLogout,
} from '../../commands/logout/logout.js'
import {
type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
logEvent,
} from '../../services/analytics/index.js'
import { getSSLErrorHint } from '../../services/api/errorUtils.js'
import { fetchAndStoreClaudeCodeFirstTokenDate } from '../../services/api/firstTokenDate.js'
import {
createAndStoreApiKey,
fetchAndStoreUserRoles,
refreshOAuthToken,
shouldUseClaudeAIAuth,
storeOAuthAccountInfo,
} from '../../services/oauth/client.js'
import { getOauthProfileFromOauthToken } from '../../services/oauth/getOauthProfile.js'
import { OAuthService } from '../../services/oauth/index.js'
import type { OAuthTokens } from '../../services/oauth/types.js'
import {
clearOAuthTokenCache,
getAnthropicApiKeyWithSource,
getAuthTokenSource,
getOauthAccountInfo,
getSubscriptionType,
isUsing3PServices,
saveOAuthTokensIfNeeded,
validateForceLoginOrg,
} from '../../utils/auth.js'
import { saveGlobalConfig } from '../../utils/config.js'
import { logForDebugging } from '../../utils/debug.js'
import { isRunningOnHomespace } from '../../utils/envUtils.js'
import { errorMessage } from '../../utils/errors.js'
import { logError } from '../../utils/log.js'
import { getAPIProvider } from '../../utils/model/providers.js'
import { getInitialSettings } from '../../utils/settings/settings.js'
import { jsonStringify } from '../../utils/slowOperations.js'
import {
buildAccountProperties,
buildAPIProviderProperties,
} from '../../utils/status.js'
/**
* Shared post-token-acquisition logic. Saves tokens, fetches profile/roles,
* and sets up the local auth state.
*/
export async function installOAuthTokens(tokens: OAuthTokens): Promise<void> {
// Clear old state before saving new credentials
await performLogout({ clearOnboarding: false })
// Reuse pre-fetched profile if available, otherwise fetch fresh
const profile =
tokens.profile ?? (await getOauthProfileFromOauthToken(tokens.accessToken))
if (profile) {
storeOAuthAccountInfo({
accountUuid: profile.account.uuid,
emailAddress: profile.account.email,
organizationUuid: profile.organization.uuid,
displayName: profile.account.display_name || undefined,
hasExtraUsageEnabled:
profile.organization.has_extra_usage_enabled ?? undefined,
billingType: profile.organization.billing_type ?? undefined,
subscriptionCreatedAt:
profile.organization.subscription_created_at ?? undefined,
accountCreatedAt: profile.account.created_at,
})
} else if (tokens.tokenAccount) {
// Fallback to token exchange account data when profile endpoint fails
storeOAuthAccountInfo({
accountUuid: tokens.tokenAccount.uuid,
emailAddress: tokens.tokenAccount.emailAddress,
organizationUuid: tokens.tokenAccount.organizationUuid,
})
}
const storageResult = saveOAuthTokensIfNeeded(tokens)
clearOAuthTokenCache()
if (storageResult.warning) {
logEvent('tengu_oauth_storage_warning', {
warning:
storageResult.warning as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
})
}
// Roles and first-token-date may fail for limited-scope tokens (e.g.
// inference-only from setup-token). They're not required for core auth.
await fetchAndStoreUserRoles(tokens.accessToken).catch(err =>
logForDebugging(String(err), { level: 'error' }),
)
if (shouldUseClaudeAIAuth(tokens.scopes)) {
await fetchAndStoreClaudeCodeFirstTokenDate().catch(err =>
logForDebugging(String(err), { level: 'error' }),
)
} else {
// API key creation is critical for Console users — let it throw.
const apiKey = await createAndStoreApiKey(tokens.accessToken)
if (!apiKey) {
throw new Error(
'Unable to create API key. The server accepted the request but did not return a key.',
)
}
}
await clearAuthRelatedCaches()
}
export async function authLogin({
email,
sso,
console: useConsole,
claudeai,
}: {
email?: string
sso?: boolean
console?: boolean
claudeai?: boolean
}): Promise<void> {
if (useConsole && claudeai) {
process.stderr.write(
'Error: --console and --claudeai cannot be used together.\n',
)
process.exit(1)
}
const settings = getInitialSettings()
// forceLoginMethod is a hard constraint (enterprise setting) — matches ConsoleOAuthFlow behavior.
// Without it, --console selects Console; --claudeai (or no flag) selects claude.ai.
const loginWithClaudeAi = settings.forceLoginMethod
? settings.forceLoginMethod === 'claudeai'
: !useConsole
const orgUUID = settings.forceLoginOrgUUID
// Fast path: if a refresh token is provided via env var, skip the browser
// OAuth flow and exchange it directly for tokens.
const envRefreshToken = process.env.CLAUDE_CODE_OAUTH_REFRESH_TOKEN
if (envRefreshToken) {
const envScopes = process.env.CLAUDE_CODE_OAUTH_SCOPES
if (!envScopes) {
process.stderr.write(
'CLAUDE_CODE_OAUTH_SCOPES is required when using CLAUDE_CODE_OAUTH_REFRESH_TOKEN.\n' +
'Set it to the space-separated scopes the refresh token was issued with\n' +
'(e.g. "user:inference" or "user:profile user:inference user:sessions:claude_code user:mcp_servers").\n',
)
process.exit(1)
}
const scopes = envScopes.split(/\s+/).filter(Boolean)
try {
logEvent('tengu_login_from_refresh_token', {})
const tokens = await refreshOAuthToken(envRefreshToken, { scopes })
await installOAuthTokens(tokens)
const orgResult = await validateForceLoginOrg()
if (!orgResult.valid) {
process.stderr.write(orgResult.message + '\n')
process.exit(1)
}
// Mark onboarding complete — interactive paths handle this via
// the Onboarding component, but the env var path skips it.
saveGlobalConfig(current => {
if (current.hasCompletedOnboarding) return current
return { ...current, hasCompletedOnboarding: true }
})
logEvent('tengu_oauth_success', {
loginWithClaudeAi: shouldUseClaudeAIAuth(tokens.scopes),
})
process.stdout.write('Login successful.\n')
process.exit(0)
} catch (err) {
logError(err)
const sslHint = getSSLErrorHint(err)
process.stderr.write(
`Login failed: ${errorMessage(err)}\n${sslHint ? sslHint + '\n' : ''}`,
)
process.exit(1)
}
}
const resolvedLoginMethod = sso ? 'sso' : undefined
const oauthService = new OAuthService()
try {
logEvent('tengu_oauth_flow_start', { loginWithClaudeAi })
const result = await oauthService.startOAuthFlow(
async url => {
process.stdout.write('Opening browser to sign in…\n')
process.stdout.write(`If the browser didn't open, visit: ${url}\n`)
},
{
loginWithClaudeAi,
loginHint: email,
loginMethod: resolvedLoginMethod,
orgUUID,
},
)
await installOAuthTokens(result)
const orgResult = await validateForceLoginOrg()
if (!orgResult.valid) {
process.stderr.write(orgResult.message + '\n')
process.exit(1)
}
logEvent('tengu_oauth_success', { loginWithClaudeAi })
process.stdout.write('Login successful.\n')
process.exit(0)
} catch (err) {
logError(err)
const sslHint = getSSLErrorHint(err)
process.stderr.write(
`Login failed: ${errorMessage(err)}\n${sslHint ? sslHint + '\n' : ''}`,
)
process.exit(1)
} finally {
oauthService.cleanup()
}
}
export async function authStatus(opts: {
json?: boolean
text?: boolean
}): Promise<void> {
const { source: authTokenSource, hasToken } = getAuthTokenSource()
const { source: apiKeySource } = getAnthropicApiKeyWithSource()
const hasApiKeyEnvVar =
!!process.env.ANTHROPIC_API_KEY && !isRunningOnHomespace()
const oauthAccount = getOauthAccountInfo()
const subscriptionType = getSubscriptionType()
const using3P = isUsing3PServices()
const loggedIn =
hasToken || apiKeySource !== 'none' || hasApiKeyEnvVar || using3P
// Determine auth method
let authMethod: string = 'none'
if (using3P) {
authMethod = 'third_party'
} else if (authTokenSource === 'claude.ai') {
authMethod = 'claude.ai'
} else if (authTokenSource === 'apiKeyHelper') {
authMethod = 'api_key_helper'
} else if (authTokenSource !== 'none') {
authMethod = 'oauth_token'
} else if (apiKeySource === 'ANTHROPIC_API_KEY' || hasApiKeyEnvVar) {
authMethod = 'api_key'
} else if (apiKeySource === '/login managed key') {
authMethod = 'claude.ai'
}
if (opts.text) {
const properties = [
...buildAccountProperties(),
...buildAPIProviderProperties(),
]
let hasAuthProperty = false
for (const prop of properties) {
const value =
typeof prop.value === 'string'
? prop.value
: Array.isArray(prop.value)
? prop.value.join(', ')
: null
if (value === null || value === 'none') {
continue
}
hasAuthProperty = true
if (prop.label) {
process.stdout.write(`${prop.label}: ${value}\n`)
} else {
process.stdout.write(`${value}\n`)
}
}
if (!hasAuthProperty && hasApiKeyEnvVar) {
process.stdout.write('API key: ANTHROPIC_API_KEY\n')
}
if (!loggedIn) {
process.stdout.write(
'Not logged in. Run claude auth login to authenticate.\n',
)
}
} else {
const apiProvider = getAPIProvider()
const resolvedApiKeySource =
apiKeySource !== 'none'
? apiKeySource
: hasApiKeyEnvVar
? 'ANTHROPIC_API_KEY'
: null
const output: Record<string, string | boolean | null> = {
loggedIn,
authMethod,
apiProvider,
}
if (resolvedApiKeySource) {
output.apiKeySource = resolvedApiKeySource
}
if (authMethod === 'claude.ai') {
output.email = oauthAccount?.emailAddress ?? null
output.orgId = oauthAccount?.organizationUuid ?? null
output.orgName = oauthAccount?.organizationName ?? null
output.subscriptionType = subscriptionType ?? null
}
process.stdout.write(jsonStringify(output, null, 2) + '\n')
}
process.exit(loggedIn ? 0 : 1)
}
export async function authLogout(): Promise<void> {
try {
await performLogout({ clearOnboarding: false })
} catch {
process.stderr.write('Failed to log out.\n')
process.exit(1)
}
process.stdout.write('Successfully logged out from your Anthropic account.\n')
process.exit(0)
}
+170
View File
@@ -0,0 +1,170 @@
/**
* Auto mode subcommand handlers — dump default/merged classifier rules and
* critique user-written rules. Dynamically imported when `claude auto-mode ...` runs.
*/
import { errorMessage } from '../../utils/errors.js'
import {
getMainLoopModel,
parseUserSpecifiedModel,
} from '../../utils/model/model.js'
import {
type AutoModeRules,
buildDefaultExternalSystemPrompt,
getDefaultExternalAutoModeRules,
} from '../../utils/permissions/yoloClassifier.js'
import { getAutoModeConfig } from '../../utils/settings/settings.js'
import { sideQuery } from '../../utils/sideQuery.js'
import { jsonStringify } from '../../utils/slowOperations.js'
function writeRules(rules: AutoModeRules): void {
process.stdout.write(jsonStringify(rules, null, 2) + '\n')
}
export function autoModeDefaultsHandler(): void {
writeRules(getDefaultExternalAutoModeRules())
}
/**
* Dump the effective auto mode config: user settings where provided, external
* defaults otherwise. Per-section REPLACE semantics — matches how
* buildYoloSystemPrompt resolves the external template (a non-empty user
* section replaces that section's defaults entirely; an empty/absent section
* falls through to defaults).
*/
export function autoModeConfigHandler(): void {
const config = getAutoModeConfig()
const defaults = getDefaultExternalAutoModeRules()
writeRules({
allow: config?.allow?.length ? config.allow : defaults.allow,
soft_deny: config?.soft_deny?.length
? config.soft_deny
: defaults.soft_deny,
environment: config?.environment?.length
? config.environment
: defaults.environment,
})
}
const CRITIQUE_SYSTEM_PROMPT =
'You are an expert reviewer of auto mode classifier rules for Claude Code.\n' +
'\n' +
'Claude Code has an "auto mode" that uses an AI classifier to decide whether ' +
'tool calls should be auto-approved or require user confirmation. Users can ' +
'write custom rules in three categories:\n' +
'\n' +
'- **allow**: Actions the classifier should auto-approve\n' +
'- **soft_deny**: Actions the classifier should block (require user confirmation)\n' +
"- **environment**: Context about the user's setup that helps the classifier make decisions\n" +
'\n' +
"Your job is to critique the user's custom rules for clarity, completeness, " +
'and potential issues. The classifier is an LLM that reads these rules as ' +
'part of its system prompt.\n' +
'\n' +
'For each rule, evaluate:\n' +
'1. **Clarity**: Is the rule unambiguous? Could the classifier misinterpret it?\n' +
"2. **Completeness**: Are there gaps or edge cases the rule doesn't cover?\n" +
'3. **Conflicts**: Do any of the rules conflict with each other?\n' +
'4. **Actionability**: Is the rule specific enough for the classifier to act on?\n' +
'\n' +
'Be concise and constructive. Only comment on rules that could be improved. ' +
'If all rules look good, say so.'
export async function autoModeCritiqueHandler(options: {
model?: string
}): Promise<void> {
const config = getAutoModeConfig()
const hasCustomRules =
(config?.allow?.length ?? 0) > 0 ||
(config?.soft_deny?.length ?? 0) > 0 ||
(config?.environment?.length ?? 0) > 0
if (!hasCustomRules) {
process.stdout.write(
'No custom auto mode rules found.\n\n' +
'Add rules to your settings file under autoMode.{allow, soft_deny, environment}.\n' +
'Run `claude auto-mode defaults` to see the default rules for reference.\n',
)
return
}
const model = options.model
? parseUserSpecifiedModel(options.model)
: getMainLoopModel()
const defaults = getDefaultExternalAutoModeRules()
const classifierPrompt = buildDefaultExternalSystemPrompt()
const userRulesSummary =
formatRulesForCritique('allow', config?.allow ?? [], defaults.allow) +
formatRulesForCritique(
'soft_deny',
config?.soft_deny ?? [],
defaults.soft_deny,
) +
formatRulesForCritique(
'environment',
config?.environment ?? [],
defaults.environment,
)
process.stdout.write('Analyzing your auto mode rules…\n\n')
let response
try {
response = await sideQuery({
querySource: 'auto_mode_critique',
model,
system: CRITIQUE_SYSTEM_PROMPT,
skipSystemPromptPrefix: true,
max_tokens: 4096,
messages: [
{
role: 'user',
content:
'Here is the full classifier system prompt that the auto mode classifier receives:\n\n' +
'<classifier_system_prompt>\n' +
classifierPrompt +
'\n</classifier_system_prompt>\n\n' +
"Here are the user's custom rules that REPLACE the corresponding default sections:\n\n" +
userRulesSummary +
'\nPlease critique these custom rules.',
},
],
})
} catch (error) {
process.stderr.write(
'Failed to analyze rules: ' + errorMessage(error) + '\n',
)
process.exitCode = 1
return
}
const textBlock = response.content.find(block => block.type === 'text')
if (textBlock?.type === 'text') {
process.stdout.write(textBlock.text + '\n')
} else {
process.stdout.write('No critique was generated. Please try again.\n')
}
}
function formatRulesForCritique(
section: string,
userRules: string[],
defaultRules: string[],
): string {
if (userRules.length === 0) return ''
const customLines = userRules.map(r => '- ' + r).join('\n')
const defaultLines = defaultRules.map(r => '- ' + r).join('\n')
return (
'## ' +
section +
' (custom rules replacing defaults)\n' +
'Custom:\n' +
customLines +
'\n\n' +
'Defaults being replaced:\n' +
defaultLines +
'\n\n'
)
}
File diff suppressed because one or more lines are too long
+878
View File
@@ -0,0 +1,878 @@
/**
* 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 52175416)
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 54335487)
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 54975565)
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 55765598)
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 56095672)
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 56905721)
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 57385769)
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 57835818)
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 58335902)
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 59185948)
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)
}
File diff suppressed because one or more lines are too long