165 lines
5.3 KiB
TypeScript
165 lines
5.3 KiB
TypeScript
/**
|
|
* Plugin-hint recommendations.
|
|
*
|
|
* Companion to lspRecommendation.ts: where LSP recommendations are triggered
|
|
* by file edits, plugin hints are triggered by CLIs/SDKs emitting a
|
|
* `<claude-code-hint />` tag to stderr (detected by the Bash/PowerShell tools).
|
|
*
|
|
* State persists in GlobalConfig.claudeCodeHints — a show-once record per
|
|
* plugin and a disabled flag (user picked "don't show again"). Official-
|
|
* marketplace filtering is hardcoded for v1.
|
|
*/
|
|
|
|
import { getFeatureValue_CACHED_MAY_BE_STALE } from '../../services/analytics/growthbook.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 {
|
|
type ClaudeCodeHint,
|
|
hasShownHintThisSession,
|
|
setPendingHint,
|
|
} from '../claudeCodeHints.js'
|
|
import { getGlobalConfig, saveGlobalConfig } from '../config.js'
|
|
import { logForDebugging } from '../debug.js'
|
|
import { isPluginInstalled } from './installedPluginsManager.js'
|
|
import { getPluginById } from './marketplaceManager.js'
|
|
import {
|
|
isOfficialMarketplaceName,
|
|
parsePluginIdentifier,
|
|
} from './pluginIdentifier.js'
|
|
import { isPluginBlockedByPolicy } from './pluginPolicy.js'
|
|
|
|
/**
|
|
* Hard cap on `claudeCodeHints.plugin[]` — bounds config growth. Each shown
|
|
* plugin appends one slug; past this point we stop prompting (and stop
|
|
* appending) rather than let the config grow without limit.
|
|
*/
|
|
const MAX_SHOWN_PLUGINS = 100
|
|
|
|
export type PluginHintRecommendation = {
|
|
pluginId: string
|
|
pluginName: string
|
|
marketplaceName: string
|
|
pluginDescription?: string
|
|
sourceCommand: string
|
|
}
|
|
|
|
/**
|
|
* Pre-store gate called by shell tools when a `type="plugin"` hint is detected.
|
|
* Drops the hint if:
|
|
*
|
|
* - a dialog has already been shown this session
|
|
* - user has disabled hints
|
|
* - the shown-plugins list has hit the config-growth cap
|
|
* - plugin slug doesn't parse as `name@marketplace`
|
|
* - marketplace isn't official (hardcoded for v1)
|
|
* - plugin is already installed
|
|
* - plugin was already shown in a prior session
|
|
*
|
|
* Synchronous on purpose — shell tools shouldn't await a marketplace lookup
|
|
* just to strip a stderr line. The async marketplace-cache check happens
|
|
* later in resolvePluginHint (hook side).
|
|
*/
|
|
export function maybeRecordPluginHint(hint: ClaudeCodeHint): void {
|
|
if (!getFeatureValue_CACHED_MAY_BE_STALE('tengu_lapis_finch', false)) return
|
|
if (hasShownHintThisSession()) return
|
|
|
|
const state = getGlobalConfig().claudeCodeHints
|
|
if (state?.disabled) return
|
|
|
|
const shown = state?.plugin ?? []
|
|
if (shown.length >= MAX_SHOWN_PLUGINS) return
|
|
|
|
const pluginId = hint.value
|
|
const { name, marketplace } = parsePluginIdentifier(pluginId)
|
|
if (!name || !marketplace) return
|
|
if (!isOfficialMarketplaceName(marketplace)) return
|
|
if (shown.includes(pluginId)) return
|
|
if (isPluginInstalled(pluginId)) return
|
|
if (isPluginBlockedByPolicy(pluginId)) return
|
|
|
|
// Bound repeat lookups on the same slug — a CLI that emits on every
|
|
// invocation shouldn't trigger N resolve cycles for the same plugin.
|
|
if (triedThisSession.has(pluginId)) return
|
|
triedThisSession.add(pluginId)
|
|
|
|
setPendingHint(hint)
|
|
}
|
|
|
|
const triedThisSession = new Set<string>()
|
|
|
|
/** Test-only reset. */
|
|
export function _resetHintRecommendationForTesting(): void {
|
|
triedThisSession.clear()
|
|
}
|
|
|
|
/**
|
|
* Resolve the pending hint to a renderable recommendation. Runs the async
|
|
* marketplace lookup that the sync pre-store gate skipped. Returns null if
|
|
* the plugin isn't in the marketplace cache — the hint is discarded.
|
|
*/
|
|
export async function resolvePluginHint(
|
|
hint: ClaudeCodeHint,
|
|
): Promise<PluginHintRecommendation | null> {
|
|
const pluginId = hint.value
|
|
const { name, marketplace } = parsePluginIdentifier(pluginId)
|
|
|
|
const pluginData = await getPluginById(pluginId)
|
|
|
|
logEvent('tengu_plugin_hint_detected', {
|
|
_PROTO_plugin_name: (name ??
|
|
'') as AnalyticsMetadata_I_VERIFIED_THIS_IS_PII_TAGGED,
|
|
_PROTO_marketplace_name: (marketplace ??
|
|
'') as AnalyticsMetadata_I_VERIFIED_THIS_IS_PII_TAGGED,
|
|
result: (pluginData
|
|
? 'passed'
|
|
: 'not_in_cache') as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
|
})
|
|
|
|
if (!pluginData) {
|
|
logForDebugging(
|
|
`[hintRecommendation] ${pluginId} not found in marketplace cache`,
|
|
)
|
|
return null
|
|
}
|
|
|
|
return {
|
|
pluginId,
|
|
pluginName: pluginData.entry.name,
|
|
marketplaceName: marketplace ?? '',
|
|
pluginDescription: pluginData.entry.description,
|
|
sourceCommand: hint.sourceCommand,
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Record that a prompt for this plugin was surfaced. Called regardless of
|
|
* the user's yes/no response — show-once semantics.
|
|
*/
|
|
export function markHintPluginShown(pluginId: string): void {
|
|
saveGlobalConfig(current => {
|
|
const existing = current.claudeCodeHints?.plugin ?? []
|
|
if (existing.includes(pluginId)) return current
|
|
return {
|
|
...current,
|
|
claudeCodeHints: {
|
|
...current.claudeCodeHints,
|
|
plugin: [...existing, pluginId],
|
|
},
|
|
}
|
|
})
|
|
}
|
|
|
|
/** Called when the user picks "don't show plugin installation hints again". */
|
|
export function disableHintRecommendations(): void {
|
|
saveGlobalConfig(current => {
|
|
if (current.claudeCodeHints?.disabled) return current
|
|
return {
|
|
...current,
|
|
claudeCodeHints: { ...current.claudeCodeHints, disabled: true },
|
|
}
|
|
})
|
|
}
|