import { execa } from 'execa' import { readFile } from 'fs/promises' import { join } from 'path' import * as React from 'react' import { useCallback, useEffect, useState } from 'react' import type { CommandResultDisplay } from '../../commands.js' import { Select } from '../../components/CustomSelect/select.js' import { Dialog } from '../../components/design-system/Dialog.js' import { Spinner } from '../../components/Spinner.js' import instances from '../../ink/instances.js' import { Box, Text } from '../../ink.js' import { enablePluginOp } from '../../services/plugins/pluginOperations.js' import { logForDebugging } from '../../utils/debug.js' import { isENOENT, toError } from '../../utils/errors.js' import { execFileNoThrow } from '../../utils/execFileNoThrow.js' import { pathExists } from '../../utils/file.js' import { logError } from '../../utils/log.js' import { getPlatform } from '../../utils/platform.js' import { clearAllCaches } from '../../utils/plugins/cacheUtils.js' import { isPluginInstalled } from '../../utils/plugins/installedPluginsManager.js' import { addMarketplaceSource, clearMarketplacesCache, loadKnownMarketplacesConfig, refreshMarketplace, } from '../../utils/plugins/marketplaceManager.js' import { OFFICIAL_MARKETPLACE_NAME } from '../../utils/plugins/officialMarketplace.js' import { loadAllPlugins } from '../../utils/plugins/pluginLoader.js' import { installSelectedPlugins } from '../../utils/plugins/pluginStartupCheck.js' // Marketplace and plugin identifiers - varies by user type const INTERNAL_MARKETPLACE_NAME = 'claude-code-marketplace' const INTERNAL_MARKETPLACE_REPO = 'anthropics/claude-code-marketplace' const OFFICIAL_MARKETPLACE_REPO = 'anthropics/claude-plugins-official' function getMarketplaceName(): string { return process.env.USER_TYPE === 'ant' ? INTERNAL_MARKETPLACE_NAME : OFFICIAL_MARKETPLACE_NAME } function getMarketplaceRepo(): string { return process.env.USER_TYPE === 'ant' ? INTERNAL_MARKETPLACE_REPO : OFFICIAL_MARKETPLACE_REPO } function getPluginId(): string { return `thinkback@${getMarketplaceName()}` } const SKILL_NAME = 'thinkback' /** * Get the thinkback skill directory from the installed plugin's cache path */ async function getThinkbackSkillDir(): Promise { const { enabled } = await loadAllPlugins() const thinkbackPlugin = enabled.find( p => p.name === 'thinkback' || (p.source && p.source.includes(getPluginId())), ) if (!thinkbackPlugin) { return null } const skillDir = join(thinkbackPlugin.path, 'skills', SKILL_NAME) if (await pathExists(skillDir)) { return skillDir } return null } export async function playAnimation(skillDir: string): Promise<{ success: boolean message: string }> { const dataPath = join(skillDir, 'year_in_review.js') const playerPath = join(skillDir, 'player.js') // Both files are prerequisites for the node subprocess. Read them here // (not at call sites) so all callers get consistent error messaging. The // subprocess runs with reject: false, so a missing file would otherwise // silently return success. Using readFile (not access) per CLAUDE.md. // // Non-ENOENT errors (EACCES etc) are logged and returned as failures rather // than thrown — the old pathExists-based code never threw, and one caller // (handleSelect) uses `void playAnimation().then(...)` without a .catch(). try { await readFile(dataPath) } catch (e: unknown) { if (isENOENT(e)) { return { success: false, message: 'No animation found. Run /think-back first to generate one.', } } logError(e) return { success: false, message: `Could not access animation data: ${toError(e).message}`, } } try { await readFile(playerPath) } catch (e: unknown) { if (isENOENT(e)) { return { success: false, message: 'Player script not found. The player.js file is missing from the thinkback skill.', } } logError(e) return { success: false, message: `Could not access player script: ${toError(e).message}`, } } // Get ink instance for terminal takeover const inkInstance = instances.get(process.stdout) if (!inkInstance) { return { success: false, message: 'Failed to access terminal instance' } } inkInstance.enterAlternateScreen() try { await execa('node', [playerPath], { stdio: 'inherit', cwd: skillDir, reject: false, }) } catch { // Animation may have been interrupted (e.g., Ctrl+C) } finally { inkInstance.exitAlternateScreen() } // Open the HTML file in browser for video download const htmlPath = join(skillDir, 'year_in_review.html') if (await pathExists(htmlPath)) { const platform = getPlatform() const openCmd = platform === 'macos' ? 'open' : platform === 'windows' ? 'start' : 'xdg-open' void execFileNoThrow(openCmd, [htmlPath]) } return { success: true, message: 'Year in review animation complete!' } } type InstallState = | { phase: 'checking' } | { phase: 'installing-marketplace' } | { phase: 'installing-plugin' } | { phase: 'enabling-plugin' } | { phase: 'ready' } | { phase: 'error'; message: string } function ThinkbackInstaller({ onReady, onError, }: { onReady: () => void onError: (message: string) => void }): React.ReactNode { const [state, setState] = useState({ phase: 'checking' }) const [progressMessage, setProgressMessage] = useState('') useEffect(() => { async function checkAndInstall(): Promise { try { // Check if marketplace is installed const knownMarketplaces = await loadKnownMarketplacesConfig() const marketplaceName = getMarketplaceName() const marketplaceRepo = getMarketplaceRepo() const pluginId = getPluginId() const marketplaceInstalled = marketplaceName in knownMarketplaces // Check if plugin is already installed first const pluginAlreadyInstalled = isPluginInstalled(pluginId) if (!marketplaceInstalled) { // Install the marketplace setState({ phase: 'installing-marketplace' }) logForDebugging(`Installing marketplace ${marketplaceRepo}`) await addMarketplaceSource( { source: 'github', repo: marketplaceRepo }, message => { setProgressMessage(message) }, ) clearAllCaches() logForDebugging(`Marketplace ${marketplaceName} installed`) } else if (!pluginAlreadyInstalled) { // Marketplace installed but plugin not installed - refresh to get latest plugins // Only refresh when needed to avoid potentially destructive git operations setState({ phase: 'installing-marketplace' }) setProgressMessage('Updating marketplace…') logForDebugging(`Refreshing marketplace ${marketplaceName}`) await refreshMarketplace(marketplaceName, message => { setProgressMessage(message) }) clearMarketplacesCache() clearAllCaches() logForDebugging(`Marketplace ${marketplaceName} refreshed`) } if (!pluginAlreadyInstalled) { // Install the plugin setState({ phase: 'installing-plugin' }) logForDebugging(`Installing plugin ${pluginId}`) const result = await installSelectedPlugins([pluginId]) if (result.failed.length > 0) { const errorMsg = result.failed .map(f => `${f.name}: ${f.error}`) .join(', ') throw new Error(`Failed to install plugin: ${errorMsg}`) } clearAllCaches() logForDebugging(`Plugin ${pluginId} installed`) } else { // Plugin is installed, check if it's enabled const { disabled } = await loadAllPlugins() const isDisabled = disabled.some( p => p.name === 'thinkback' || p.source?.includes(pluginId), ) if (isDisabled) { // Enable the plugin setState({ phase: 'enabling-plugin' }) logForDebugging(`Enabling plugin ${pluginId}`) const enableResult = await enablePluginOp(pluginId) if (!enableResult.success) { throw new Error( `Failed to enable plugin: ${enableResult.message}`, ) } clearAllCaches() logForDebugging(`Plugin ${pluginId} enabled`) } } setState({ phase: 'ready' }) onReady() } catch (error) { const err = toError(error) logError(err) setState({ phase: 'error', message: err.message }) onError(err.message) } } void checkAndInstall() }, [onReady, onError]) if (state.phase === 'error') { return ( Error: {state.message} ) } if (state.phase === 'ready') { return null } const statusMessage = state.phase === 'checking' ? 'Checking thinkback installation…' : state.phase === 'installing-marketplace' ? 'Installing marketplace…' : state.phase === 'enabling-plugin' ? 'Enabling thinkback plugin…' : 'Installing thinkback plugin…' return ( {progressMessage || statusMessage} ) } type MenuAction = 'play' | 'edit' | 'fix' | 'regenerate' type GenerativeAction = Exclude function ThinkbackMenu({ onDone, onAction, skillDir, hasGenerated, }: { onDone: ( result?: string, options?: { display?: CommandResultDisplay; shouldQuery?: boolean }, ) => void onAction: (action: GenerativeAction) => void skillDir: string hasGenerated: boolean }): React.ReactNode { const [hasSelected, setHasSelected] = useState(false) const options = hasGenerated ? [ { label: 'Play animation', value: 'play' as const, description: 'Watch your year in review', }, { label: 'Edit content', value: 'edit' as const, description: 'Modify the animation', }, { label: 'Fix errors', value: 'fix' as const, description: 'Fix validation or rendering issues', }, { label: 'Regenerate', value: 'regenerate' as const, description: 'Create a new animation from scratch', }, ] : [ { label: "Let's go!", value: 'regenerate' as const, description: 'Generate your personalized animation', }, ] function handleSelect(value: MenuAction): void { setHasSelected(true) if (value === 'play') { // Play runs the terminal-takeover animation, then signal done with skip void playAnimation(skillDir).then(() => { onDone(undefined, { display: 'skip' }) }) } else { onAction(value) } } function handleCancel(): void { onDone(undefined, { display: 'skip' }) } if (hasSelected) { return null } return ( {/* Description for first-time users */} {!hasGenerated && ( Relive your year of coding with Claude. { "We'll create a personalized ASCII animation celebrating your journey." } )} {/* Menu */}