/ utils / plugins / hintRecommendation.ts
hintRecommendation.ts
  1  /**
  2   * Plugin-hint recommendations.
  3   *
  4   * Companion to lspRecommendation.ts: where LSP recommendations are triggered
  5   * by file edits, plugin hints are triggered by CLIs/SDKs emitting a
  6   * `<claude-code-hint />` tag to stderr (detected by the Bash/PowerShell tools).
  7   *
  8   * State persists in GlobalConfig.claudeCodeHints — a show-once record per
  9   * plugin and a disabled flag (user picked "don't show again"). Official-
 10   * marketplace filtering is hardcoded for v1.
 11   */
 12  
 13  import { getFeatureValue_CACHED_MAY_BE_STALE } from '../../services/analytics/growthbook.js'
 14  import {
 15    type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
 16    type AnalyticsMetadata_I_VERIFIED_THIS_IS_PII_TAGGED,
 17    logEvent,
 18  } from '../../services/analytics/index.js'
 19  import {
 20    type ClaudeCodeHint,
 21    hasShownHintThisSession,
 22    setPendingHint,
 23  } from '../claudeCodeHints.js'
 24  import { getGlobalConfig, saveGlobalConfig } from '../config.js'
 25  import { logForDebugging } from '../debug.js'
 26  import { isPluginInstalled } from './installedPluginsManager.js'
 27  import { getPluginById } from './marketplaceManager.js'
 28  import {
 29    isOfficialMarketplaceName,
 30    parsePluginIdentifier,
 31  } from './pluginIdentifier.js'
 32  import { isPluginBlockedByPolicy } from './pluginPolicy.js'
 33  
 34  /**
 35   * Hard cap on `claudeCodeHints.plugin[]` — bounds config growth. Each shown
 36   * plugin appends one slug; past this point we stop prompting (and stop
 37   * appending) rather than let the config grow without limit.
 38   */
 39  const MAX_SHOWN_PLUGINS = 100
 40  
 41  export type PluginHintRecommendation = {
 42    pluginId: string
 43    pluginName: string
 44    marketplaceName: string
 45    pluginDescription?: string
 46    sourceCommand: string
 47  }
 48  
 49  /**
 50   * Pre-store gate called by shell tools when a `type="plugin"` hint is detected.
 51   * Drops the hint if:
 52   *
 53   *  - a dialog has already been shown this session
 54   *  - user has disabled hints
 55   *  - the shown-plugins list has hit the config-growth cap
 56   *  - plugin slug doesn't parse as `name@marketplace`
 57   *  - marketplace isn't official (hardcoded for v1)
 58   *  - plugin is already installed
 59   *  - plugin was already shown in a prior session
 60   *
 61   * Synchronous on purpose — shell tools shouldn't await a marketplace lookup
 62   * just to strip a stderr line. The async marketplace-cache check happens
 63   * later in resolvePluginHint (hook side).
 64   */
 65  export function maybeRecordPluginHint(hint: ClaudeCodeHint): void {
 66    if (!getFeatureValue_CACHED_MAY_BE_STALE('tengu_lapis_finch', false)) return
 67    if (hasShownHintThisSession()) return
 68  
 69    const state = getGlobalConfig().claudeCodeHints
 70    if (state?.disabled) return
 71  
 72    const shown = state?.plugin ?? []
 73    if (shown.length >= MAX_SHOWN_PLUGINS) return
 74  
 75    const pluginId = hint.value
 76    const { name, marketplace } = parsePluginIdentifier(pluginId)
 77    if (!name || !marketplace) return
 78    if (!isOfficialMarketplaceName(marketplace)) return
 79    if (shown.includes(pluginId)) return
 80    if (isPluginInstalled(pluginId)) return
 81    if (isPluginBlockedByPolicy(pluginId)) return
 82  
 83    // Bound repeat lookups on the same slug — a CLI that emits on every
 84    // invocation shouldn't trigger N resolve cycles for the same plugin.
 85    if (triedThisSession.has(pluginId)) return
 86    triedThisSession.add(pluginId)
 87  
 88    setPendingHint(hint)
 89  }
 90  
 91  const triedThisSession = new Set<string>()
 92  
 93  /** Test-only reset. */
 94  export function _resetHintRecommendationForTesting(): void {
 95    triedThisSession.clear()
 96  }
 97  
 98  /**
 99   * Resolve the pending hint to a renderable recommendation. Runs the async
100   * marketplace lookup that the sync pre-store gate skipped. Returns null if
101   * the plugin isn't in the marketplace cache — the hint is discarded.
102   */
103  export async function resolvePluginHint(
104    hint: ClaudeCodeHint,
105  ): Promise<PluginHintRecommendation | null> {
106    const pluginId = hint.value
107    const { name, marketplace } = parsePluginIdentifier(pluginId)
108  
109    const pluginData = await getPluginById(pluginId)
110  
111    logEvent('tengu_plugin_hint_detected', {
112      _PROTO_plugin_name: (name ??
113        '') as AnalyticsMetadata_I_VERIFIED_THIS_IS_PII_TAGGED,
114      _PROTO_marketplace_name: (marketplace ??
115        '') as AnalyticsMetadata_I_VERIFIED_THIS_IS_PII_TAGGED,
116      result: (pluginData
117        ? 'passed'
118        : 'not_in_cache') as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
119    })
120  
121    if (!pluginData) {
122      logForDebugging(
123        `[hintRecommendation] ${pluginId} not found in marketplace cache`,
124      )
125      return null
126    }
127  
128    return {
129      pluginId,
130      pluginName: pluginData.entry.name,
131      marketplaceName: marketplace ?? '',
132      pluginDescription: pluginData.entry.description,
133      sourceCommand: hint.sourceCommand,
134    }
135  }
136  
137  /**
138   * Record that a prompt for this plugin was surfaced. Called regardless of
139   * the user's yes/no response — show-once semantics.
140   */
141  export function markHintPluginShown(pluginId: string): void {
142    saveGlobalConfig(current => {
143      const existing = current.claudeCodeHints?.plugin ?? []
144      if (existing.includes(pluginId)) return current
145      return {
146        ...current,
147        claudeCodeHints: {
148          ...current.claudeCodeHints,
149          plugin: [...existing, pluginId],
150        },
151      }
152    })
153  }
154  
155  /** Called when the user picks "don't show plugin installation hints again". */
156  export function disableHintRecommendations(): void {
157    saveGlobalConfig(current => {
158      if (current.claudeCodeHints?.disabled) return current
159      return {
160        ...current,
161        claudeCodeHints: { ...current.claudeCodeHints, disabled: true },
162      }
163    })
164  }