diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..4ee896e --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,25 @@ +# AGENTS.md - ampcode-wrapped + +## Commands +- **Start**: `bun start` - Run the CLI tool +- **Dev**: `bun run --watch src/index.ts` - Watch mode for development +- **Build**: `bun build src/index.ts --outdir dist --target bun` +- **Type check**: `bun run typecheck` or `tsc --noEmit` + +## CLI Flags +`--year `, `--output/-o `, `--theme `, `--svg`, `--json`, `--yes/-y`, `--quiet/-q`, `--no-clipboard`, `--no-terminal-image` + +## Architecture +Spotify Wrapped-style stats visualization for Amp usage. Single-purpose CLI tool. + +**Core Flow**: Collector (`collector.ts`) → Stats (`stats.ts`) → Generator (`image/generator.tsx`) → Display/Share +- `types.ts` - Thread/Message data structures, AmpStats interface (includes peakHourPersona, longestThread, avgTokensPerDay) +- `image/` - React template, design-tokens.ts (dark/light themes), heatmap visualization +- `terminal/` - Inline image rendering; `clipboard/` - macOS/Linux clipboard + +## Code Style +- **TypeScript**: `strict: true`, no `any` types, ESM imports +- **Naming**: camelCase functions, PascalCase types, SCREAMING_SNAKE_CASE constants +- **Async/IO**: Use async/await with Bun.file(); use native Map for collections +- **React**: JSX with Satori (no hooks), inline style objects, useTheme() for colors +- **Errors**: Throw descriptive errors, let main catch and display diff --git a/README.md b/README.md deleted file mode 100644 index cd7522c..0000000 --- a/README.md +++ /dev/null @@ -1,89 +0,0 @@ -# ampcode-wrapped - -Generate a personalized Spotify Wrapped-style summary of your [Amp](https://ampcode.com) usage. - -![ampcode-wrapped example](./assets/images/example.png) - -## Features - -- Scans your local Amp thread history -- Generates a beautiful stats card image -- Shows threads, messages, tokens, and credits used -- Activity heatmap visualization for the year -- Top tools used analysis -- Coding hours breakdown -- Streak tracking -- Auto-copy to clipboard -- Share directly to X/Twitter - - -### Run Locally - -```bash -git clone https://github.com/rezhajulio/ampcode-wrapped.git -cd ampcode-wrapped -bun install -bun start -``` - -## CLI Options - -``` -ampcode-wrapped [OPTIONS] - -OPTIONS: - --year Generate wrapped for a specific year (default: current year) - --data-dir Override data directory (default: ~/.local/share/amp/threads) - --output, -o Save image directly to specified path (skips save prompt) - --format Image layout format (default: default) - default: 1500x1700 - standard layout - --theme Color theme: dark or light (default: dark) - --svg Export as SVG instead of PNG - --json Output stats as JSON to stdout and exit (no image generation) - --yes, -y Skip confirmation prompts (auto-save, skip share prompt) - --quiet, -q Suppress rich prompts (no intro/outro/spinners) - --no-clipboard Skip copying image to clipboard - --no-terminal-image Skip displaying image in terminal - --help, -h Show help message - --version, -v Show version number - -ENVIRONMENT: - CI=true Auto-disables clipboard and terminal image display -``` - -### Examples - -```bash -ampcode-wrapped # Generate current year wrapped -ampcode-wrapped --year 2025 # Generate 2025 wrapped -ampcode-wrapped --theme light # Use light theme -ampcode-wrapped -o ~/Desktop/amp.png # Save directly to path -ampcode-wrapped --json # Output raw stats as JSON -``` - -## Privacy - -**All data stays on your machine.** ampcode-wrapped: - -- Reads thread data only from your local `~/.local/share/amp/threads` directory -- Never transmits any data over the network -- Generates images locally using Satori and Resvg -- Does not collect analytics or telemetry - -Your Amp usage data never leaves your computer. - -## Requirements - -- [Amp](https://ampcode.com) installed and used at least once - -## License - -MIT - see [LICENSE](./LICENSE) - -## Author - -[Rezha Julio](https://github.com/rezhajulio) - -## Acknowledgments - -- Inspired by https://github.com/moddi3/opencode-wrapped diff --git a/assets/images/example.png b/assets/images/example.png index 4516800..34132d6 100644 Binary files a/assets/images/example.png and b/assets/images/example.png differ diff --git a/src/image/components/Charts.tsx b/src/image/components/Charts.tsx deleted file mode 100644 index 163c67b..0000000 --- a/src/image/components/Charts.tsx +++ /dev/null @@ -1,105 +0,0 @@ -import type { WeekdayActivity } from "../../types"; -import { typography, spacing, components, type ThemeColors } from "../design-tokens"; -import { createScaler, flexColumn, flexRow } from "../styles"; - -export const BAR_HEIGHT = 100; -export const BAR_WIDTH = 56; -export const BAR_GAP = 12; -export const WEEKDAY_LABELS = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"]; - -const HOURLY_BAR_HEIGHT = 32; -const HOURLY_BAR_WIDTH = 12; -const HOURLY_BAR_GAP = 4; - -interface WeeklyBarChartProps { - weekdayActivity: WeekdayActivity; - colors: ThemeColors; - scale?: number; -} - -export function WeeklyBarChart({ weekdayActivity, colors, scale = 1 }: WeeklyBarChartProps) { - const { counts, mostActiveDay, maxCount } = weekdayActivity; - const s = createScaler(scale); - const scaledBarHeight = s(BAR_HEIGHT); - const scaledBarWidth = s(BAR_WIDTH); - const scaledBarGap = s(BAR_GAP); - - return ( -
-
- {counts.map((count, i) => { - const heightPercent = maxCount > 0 ? count / maxCount : 0; - const barHeight = Math.max(s(8), Math.round(heightPercent * scaledBarHeight)); - const isHighlighted = i === mostActiveDay; - - return
; - })} -
-
- {WEEKDAY_LABELS.map((label, i) => { - const isHighlighted = i === mostActiveDay; - return ( -
- {label} -
- ); - })} -
-
- ); -} - -interface CodingHoursCardProps { - hourlyStats: { peakHour: number; peakPeriod: string; peakPeriodEmoji: string }; - hourlyActivity: number[]; - colors: ThemeColors; - scale?: number; -} - -export function CodingHoursCard({ hourlyStats, hourlyActivity, colors, scale = 1 }: CodingHoursCardProps) { - const s = createScaler(scale); - const twoHourBlocks: number[] = []; - for (let i = 0; i < 24; i += 2) { - twoHourBlocks.push(hourlyActivity[i] + hourlyActivity[i + 1]); - } - const maxBlock = Math.max(...twoHourBlocks); - const peakBlockIndex = Math.floor(hourlyStats.peakHour / 2); - const scaledBarHeight = s(HOURLY_BAR_HEIGHT); - const scaledBarWidth = s(HOURLY_BAR_WIDTH); - const scaledBarGap = s(HOURLY_BAR_GAP); - - return ( -
- - Coding Hours - -
-
- Peak Hour - {hourlyStats.peakHour}:00 - {(hourlyStats.peakHour + 1) % 24}:00 -
-
- Peak Period - {hourlyStats.peakPeriod} -
-
-
- {twoHourBlocks.map((val, i) => { - const heightPercent = maxBlock > 0 ? val / maxBlock : 0; - const barHeight = Math.max(2, Math.round(heightPercent * scaledBarHeight)); - const isPeak = i === peakBlockIndex; - return
; - })} -
-
- 0 - 12 - 24 -
-
-
-
- ); -} - - diff --git a/src/image/components/Footer.tsx b/src/image/components/Footer.tsx deleted file mode 100644 index 6d396cb..0000000 --- a/src/image/components/Footer.tsx +++ /dev/null @@ -1,18 +0,0 @@ -import { typography, spacing, type ThemeColors } from "../design-tokens"; -import { createScaler, centerContent } from "../styles"; - -interface FooterProps { - colors: ThemeColors; - scale?: number; -} - -export function Footer({ colors, scale = 1 }: FooterProps) { - const s = createScaler(scale); - return ( -
- ampcode.com - - github.com/rezhajulio/ampcode-wrapped -
- ); -} diff --git a/src/image/components/index.ts b/src/image/components/index.ts deleted file mode 100644 index 2e105b1..0000000 --- a/src/image/components/index.ts +++ /dev/null @@ -1,7 +0,0 @@ -export { Header } from "./Header"; -export { Footer } from "./Footer"; -export { StatBox, CompactStatBox, MiniStat } from "./StatBox"; -export { WeeklyBarChart, CodingHoursCard, BAR_GAP } from "./Charts"; -export { SectionTitle, Section, RankingList, ByTheNumbers } from "./Sections"; -export { HeroStatItem } from "./HeroStat"; -export { StatsGrid } from "./StatsGrid"; diff --git a/src/image/design-tokens.ts b/src/image/design-tokens.ts deleted file mode 100644 index 018265d..0000000 --- a/src/image/design-tokens.ts +++ /dev/null @@ -1,288 +0,0 @@ -export type ThemeName = "dark" | "light"; - -export interface ThemeColors { - background: string; - surface: string; - surfaceHover: string; - surfaceBorder: string; - text: { - primary: string; - secondary: string; - tertiary: string; - muted: string; - disabled: string; - }; - accent: { - primary: string; - primaryHover: string; - secondary: string; - }; - heatmap: { - empty: string; - level1: string; - level2: string; - level3: string; - level4: string; - level5: string; - level6: string; - }; - streak: { - empty: string; - level1: string; - level2: string; - level3: string; - level4: string; - level5: string; - level6: string; - }; -} - -const darkColors: ThemeColors = { - background: "#0A0A0A", - surface: "#161616", - surfaceHover: "#1C1C1C", - surfaceBorder: "#262626", - - text: { - primary: "#FAFAFA", - secondary: "#E5E5E5", - tertiary: "#A3A3A3", - muted: "#737373", - disabled: "#525252", - }, - - accent: { - primary: "#22c55e", - primaryHover: "#16a34a", - secondary: "#3B82F6", - }, - - heatmap: { - empty: "#1A1A1A", - level1: "#2D2D2D", - level2: "#424242", - level3: "#5C5C5C", - level4: "#7A7A7A", - level5: "#9E9E9E", - level6: "#C4C4C4", - }, - - streak: { - empty: "#0D1A0D", - level1: "#14532d", - level2: "#166534", - level3: "#22c55e", - level4: "#4ade80", - level5: "#86efac", - level6: "#bbf7d0", - }, -}; - -const lightColors: ThemeColors = { - background: "#FFFFFF", - surface: "#F5F5F5", - surfaceHover: "#EBEBEB", - surfaceBorder: "#E0E0E0", - - text: { - primary: "#171717", - secondary: "#262626", - tertiary: "#525252", - muted: "#737373", - disabled: "#A3A3A3", - }, - - accent: { - primary: "#16a34a", - primaryHover: "#15803d", - secondary: "#2563EB", - }, - - heatmap: { - empty: "#F0F0F0", - level1: "#D4D4D4", - level2: "#A3A3A3", - level3: "#737373", - level4: "#525252", - level5: "#404040", - level6: "#262626", - }, - - streak: { - empty: "#DCFCE7", - level1: "#BBF7D0", - level2: "#86EFAC", - level3: "#4ADE80", - level4: "#22C55E", - level5: "#16A34A", - level6: "#15803D", - }, -}; - -export const themes: Record = { - dark: darkColors, - light: lightColors, -}; - -export const colors = darkColors; - -export const typography = { - fontFamily: { - mono: "IBM Plex Mono", - }, - weight: { - regular: 400, - medium: 500, - semibold: 600, - bold: 700, - }, - size: { - xs: 12, - sm: 14, - base: 16, - md: 20, - lg: 24, - xl: 32, - "2xl": 40, - "3xl": 48, - "4xl": 56, - "5xl": 64, - }, - lineHeight: { - none: 1, - tight: 1.15, - snug: 1.25, - normal: 1.4, - relaxed: 1.5, - }, - letterSpacing: { - tighter: -2, - tight: -1, - normal: 0, - wide: 1, - wider: 2, - widest: 4, - }, -} as const; - -export const spacing = { - 0: 0, - 1: 4, - 2: 8, - 3: 12, - 4: 16, - 5: 20, - 6: 24, - 8: 32, - 10: 40, - 12: 48, - 14: 56, - 16: 64, - 20: 80, - 24: 96, -} as const; - -export const layout = { - canvas: { - width: 1500, - height: 1700, - }, - padding: { - horizontal: 64, - top: 80, - bottom: 64, - }, - content: { - width: 1372, - }, - radius: { - none: 0, - sm: 4, - md: 8, - lg: 12, - xl: 16, - "2xl": 24, - full: 9999, - }, -} as const; - -export type LayoutFormat = "default"; - -export interface FormatConfig { - width: number; - height: number; - padding: { horizontal: number; top: number; bottom: number }; - scale: number; - heatmapCellSize: number; - heatmapGap: number; -} - -export const formatConfigs: Record = { - default: { - width: 1500, - height: 1700, - padding: { horizontal: 64, top: 80, bottom: 64 }, - scale: 1, - heatmapCellSize: 23.4, - heatmapGap: 3, - }, -}; - -export const components = { - statBox: { - background: colors.surface, - borderRadius: layout.radius.lg, - padding: { x: 32, y: 24 }, - gap: 8, - }, - card: { - background: colors.surface, - borderRadius: layout.radius.lg, - borderColor: colors.surfaceBorder, - padding: spacing[6], - }, - sectionHeader: { - fontSize: typography.size.lg, - fontWeight: typography.weight.medium, - color: colors.text.tertiary, - letterSpacing: typography.letterSpacing.wider, - textTransform: "uppercase" as const, - }, - heatmapCell: { - size: 23.4, - gap: 3, - borderRadius: layout.radius.sm, - }, - legend: { - fontSize: typography.size.xs, - color: colors.text.muted, - cellSize: 14, - gap: 6, - }, - ranking: { - numberWidth: 48, - numberSize: typography.size.xl, - itemSize: typography.size.lg, - gap: spacing[4], - }, -} as const; - -export const HEATMAP_COLORS = { - 0: colors.heatmap.empty, - 1: colors.heatmap.level1, - 2: colors.heatmap.level2, - 3: colors.heatmap.level3, - 4: colors.heatmap.level4, - 5: colors.heatmap.level5, - 6: colors.heatmap.level6, -} as const; - -export const STREAK_COLORS = { - 0: colors.streak.empty, - 1: colors.streak.level1, - 2: colors.streak.level2, - 3: colors.streak.level3, - 4: colors.streak.level4, - 5: colors.streak.level5, - 6: colors.streak.level6, -} as const; diff --git a/src/image/template.tsx b/src/image/template.tsx deleted file mode 100644 index bff3f73..0000000 --- a/src/image/template.tsx +++ /dev/null @@ -1,71 +0,0 @@ -import type { AmpStats } from "../types"; -import { formatNumber, formatCredits, formatDate } from "../utils/format"; -import { getHourlyStats } from "../stats"; -import { ActivityHeatmap } from "./heatmap"; -import { typography, spacing, layout, formatConfigs, type ThemeColors, type LayoutFormat } from "./design-tokens"; -import { - Header, - Footer, - WeeklyBarChart, - CodingHoursCard, - SectionTitle, - Section, - RankingList, - ByTheNumbers, - HeroStatItem, - StatsGrid, -} from "./components"; - -interface TemplateProps { - stats: AmpStats; - theme: ThemeColors; - layoutFormat?: LayoutFormat; -} - -export function WrappedTemplate({ stats, theme, layoutFormat = "default" }: TemplateProps) { - const hourlyStats = getHourlyStats(stats.hourlyActivity); - const colors = theme; - const config = formatConfigs[layoutFormat]; - - return ( -
-
- -
- - -
- Weekly - -
-
- -
- -
- -
- ({ name: t.name }))} colors={colors} scale={1} /> - - -
- - -
-
- ); -} diff --git a/src/index.ts b/src/index.ts deleted file mode 100644 index f980d3d..0000000 --- a/src/index.ts +++ /dev/null @@ -1,438 +0,0 @@ -#!/usr/bin/env bun - -import * as p from "@clack/prompts"; -import { join } from "node:path"; -import { parseArgs } from "node:util"; -import { homedir } from "node:os"; - -import { checkAmpDataExists, getAmpDataDir, getAvailableYears } from "./collector"; -import { calculateStats } from "./stats"; -import { generateImage, type GeneratedImage, type GeneratedSvg, type ImageFormat } from "./image/generator"; -import type { ThemeName, LayoutFormat } from "./image/design-tokens"; -import { displayInTerminal, getTerminalName } from "./terminal/display"; -import { copyImageToClipboard } from "./clipboard"; -import { formatNumber } from "./utils/format"; -import type { AmpStats } from "./types"; - -const VERSION = "1.0.0"; - -function serializeStats(stats: AmpStats): Record { - return { - ...stats, - firstThreadDate: stats.firstThreadDate.toISOString(), - dailyActivity: Object.fromEntries(stats.dailyActivity), - clientUsage: Object.fromEntries(stats.clientUsage), - maxStreakDays: Array.from(stats.maxStreakDays), - }; -} - -function printHelp() { - console.log(` -ampcode-wrapped v${VERSION} - -Generate your Amp year in review stats card. - -USAGE: - ampcode-wrapped [OPTIONS] - -OPTIONS: - --year Generate wrapped for a specific year (default: current year) - --data-dir Override data directory (default: ~/.local/share/amp/threads) - --output, -o Save image directly to specified path (skips save prompt) - --format Image layout format (default: default) - default: 1500x1700 - standard layout - --theme Color theme (default: dark) - --svg Export as SVG instead of PNG - --json Output stats as JSON to stdout and exit (no image generation) - --yes, -y Skip confirmation prompts (auto-save, skip share prompt) - --quiet, -q Suppress rich prompts (no intro/outro/spinners) - --no-clipboard Skip copying image to clipboard - --no-terminal-image Skip displaying image in terminal - --help, -h Show this help message - --version, -v Show version number - -ENVIRONMENT: - CI=true Auto-disables clipboard and terminal image display - -EXAMPLES: - ampcode-wrapped # Generate current year wrapped - ampcode-wrapped --year 2025 # Generate 2025 wrapped -`); -} - -async function main() { - const { values } = parseArgs({ - args: process.argv.slice(2), - options: { - year: { type: "string" }, - "data-dir": { type: "string" }, - output: { type: "string", short: "o" }, - format: { type: "string" }, - theme: { type: "string" }, - svg: { type: "boolean" }, - json: { type: "boolean" }, - yes: { type: "boolean", short: "y" }, - quiet: { type: "boolean", short: "q" }, - "no-clipboard": { type: "boolean" }, - "no-terminal-image": { type: "boolean" }, - help: { type: "boolean", short: "h" }, - version: { type: "boolean", short: "v" }, - }, - strict: true, - allowPositionals: false, - }); - - const isCI = process.env.CI === "true"; - const autoConfirm = values.yes ?? false; - const quietMode = values.quiet ?? false; - const outputPath = values.output; - const svgFormat = values.svg ?? false; - const jsonOutput = values.json ?? false; - const noClipboard = values["no-clipboard"] ?? isCI; - const noTerminalImage = values["no-terminal-image"] ?? isCI; - const imageFormat: ImageFormat = svgFormat ? "svg" : "png"; - const theme: ThemeName = values.theme === "light" ? "light" : "dark"; - - let layoutFormat: LayoutFormat = "default"; - if (values.format && values.format !== "default") { - const errorMsg = `Invalid format "${values.format}". Valid option: default`; - if (quietMode) { - console.error(errorMsg); - } else { - p.cancel(errorMsg); - } - process.exit(1); - } - - if (values.help) { - printHelp(); - process.exit(0); - } - - if (values.version) { - console.log(`ampcode-wrapped v${VERSION}`); - process.exit(0); - } - - if (!quietMode) { - p.intro("ampcode wrapped"); - } - - const currentYear = new Date().getFullYear(); - const customDataDir = values["data-dir"]; - - let requestedYear: number | undefined; - if (values.year) { - const parsed = parseInt(values.year, 10); - const minYear = 2020; - const maxYear = currentYear + 1; - - if (isNaN(parsed) || !/^\d{4}$/.test(values.year)) { - const errorMsg = `Invalid year "${values.year}". Please provide a valid 4-digit year (e.g., --year 2025).`; - if (quietMode) { - console.error(errorMsg); - } else { - p.cancel(errorMsg); - } - process.exit(1); - } - - if (parsed < minYear || parsed > maxYear) { - const errorMsg = `Year ${parsed} is out of range. Please provide a year between ${minYear} and ${maxYear}.`; - if (quietMode) { - console.error(errorMsg); - } else { - p.cancel(errorMsg); - } - process.exit(1); - } - - requestedYear = parsed; - } else { - requestedYear = currentYear; - } - - const dataExists = await checkAmpDataExists(); - if (!dataExists && !customDataDir) { - if (quietMode) { - console.error(`Amp data not found in ${getAmpDataDir()}`); - } else { - p.cancel(`Amp data not found in ${getAmpDataDir()}\n\nMake sure you have used Amp at least once.`); - } - process.exit(0); - } - - const spinner = quietMode ? null : p.spinner(); - if (spinner) { - spinner.start("Scanning your Amp history..."); - } else { - console.log("Scanning your Amp history..."); - } - - let stats: AmpStats; - try { - stats = await calculateStats(requestedYear, customDataDir); - } catch (error) { - if (spinner) { - spinner.stop("Failed to collect stats"); - p.cancel(`Error: ${error}`); - } else { - console.error(`Error: ${error}`); - } - process.exit(1); - } - - if (stats.totalThreads === 0) { - if (spinner) { - spinner.stop("No data found"); - } - - let errorMsg = `No Amp activity found for ${requestedYear}.`; - - if (values.year) { - const availableYears = await getAvailableYears(customDataDir); - if (availableYears.length > 0) { - const yearsStr = availableYears.slice(0, 3).join(", "); - errorMsg += `\n\nActivity found for: ${yearsStr}${availableYears.length > 3 ? "..." : ""}`; - errorMsg += `\nTry omitting --year to see all activity, or use --year ${availableYears[0]}.`; - } - } - - if (quietMode) { - console.error(errorMsg); - } else { - p.cancel(errorMsg); - } - process.exit(0); - } - - if (spinner) { - spinner.stop("Found your stats!"); - } - - if (jsonOutput) { - console.log(JSON.stringify(serializeStats(stats), null, 2)); - process.exit(0); - } - - const summaryLines = [ - `Threads: ${formatNumber(stats.totalThreads)}`, - `Messages: ${formatNumber(stats.totalMessages)}`, - `Total Tokens: ${formatNumber(stats.totalTokens)}`, - `Projects: ${formatNumber(stats.totalProjects)}`, - `Streak: ${stats.maxStreak} days`, - stats.totalCredits > 0 && `Credits: $${stats.totalCredits.toFixed(2)}`, - stats.mostActiveDay && `Most Active: ${stats.mostActiveDay.formattedDate}`, - ].filter(Boolean); - - if (!quietMode) { - p.note(summaryLines.join("\n"), `Your ${requestedYear} in Amp`); - } else { - console.log(`\nYour ${requestedYear} in Amp:`); - summaryLines.forEach((line) => console.log(` ${line}`)); - } - - if (spinner) { - spinner.start("Generating your wrapped image..."); - } else { - console.log("Generating your wrapped image..."); - } - - let result: GeneratedImage | GeneratedSvg; - try { - result = await generateImage(stats, { format: imageFormat, theme, layoutFormat }); - } catch (error) { - if (spinner) { - spinner.stop("Failed to generate image"); - p.cancel(`Error generating image: ${error}`); - } else { - console.error(`Error generating image: ${error}`); - } - process.exit(1); - } - - if (spinner) { - spinner.stop("Image generated!"); - } else { - console.log("Image generated!"); - } - - const fileExtension = imageFormat === "svg" ? "svg" : "png"; - const formatSuffix = layoutFormat !== "default" ? `-${layoutFormat}` : ""; - const filename = `ampcode-wrapped-${requestedYear}${formatSuffix}.${fileExtension}`; - - if ("svg" in result) { - const savePath = outputPath ?? join(homedir(), filename); - - let shouldSave: boolean; - if (outputPath) { - shouldSave = true; - } else if (autoConfirm) { - shouldSave = true; - } else { - const saveResult = await p.confirm({ - message: `Save SVG to ~/${filename}?`, - initialValue: true, - }); - if (p.isCancel(saveResult)) { - if (!quietMode) { - p.outro("Cancelled"); - } - process.exit(0); - } - shouldSave = saveResult; - } - - if (shouldSave) { - try { - await Bun.write(savePath, result.svg); - if (!quietMode) { - p.log.success(`Saved to ${savePath}`); - } else { - console.log(`Saved to ${savePath}`); - } - } catch (error) { - if (!quietMode) { - p.log.error(`Failed to save: ${error}`); - } else { - console.error(`Failed to save: ${error}`); - } - } - } - } else { - const image = result; - - if (!noTerminalImage) { - const displayed = await displayInTerminal(image.displaySize); - if (!displayed && !quietMode) { - p.log.info(`Terminal (${getTerminalName()}) doesn't support inline images`); - } - } - - if (!noClipboard) { - const { success, error } = await copyImageToClipboard(image.fullSize, filename); - - if (success) { - if (!quietMode) { - p.log.success("Automatically copied image to clipboard!"); - } else { - console.log("Copied image to clipboard"); - } - } else if (!quietMode) { - p.log.warn(`Clipboard unavailable: ${error}`); - p.log.info("You can save the image to disk instead."); - } - } - - const savePath = outputPath ?? join(homedir(), filename); - - let shouldSave: boolean; - if (outputPath) { - shouldSave = true; - } else if (autoConfirm) { - shouldSave = true; - } else { - const saveResult = await p.confirm({ - message: `Save image to ~/${filename}?`, - initialValue: true, - }); - if (p.isCancel(saveResult)) { - if (!quietMode) { - p.outro("Cancelled"); - } - process.exit(0); - } - shouldSave = saveResult; - } - - if (shouldSave) { - try { - await Bun.write(savePath, image.fullSize); - if (!quietMode) { - p.log.success(`Saved to ${savePath}`); - } else { - console.log(`Saved to ${savePath}`); - } - } catch (error) { - if (!quietMode) { - p.log.error(`Failed to save: ${error}`); - } else { - console.error(`Failed to save: ${error}`); - } - } - } - } - - if (!autoConfirm) { - const shouldShare = await p.confirm({ - message: "Share on X (Twitter)? Don't forget to attach your image!", - initialValue: true, - }); - - if (!p.isCancel(shouldShare) && shouldShare) { - const tweetUrl = generateTweetUrl(stats); - const opened = await openUrl(tweetUrl); - if (opened) { - p.log.success("Opened X in your browser."); - } else { - p.log.warn("Couldn't open browser. Copy this URL:"); - p.log.info(tweetUrl); - } - } - } - - if (!quietMode) { - p.outro("Share your wrapped!"); - } - process.exit(0); -} - -function generateTweetUrl(stats: AmpStats): string { - const text = [ - `my ${stats.year} ampcode wrapped:`, - ``, - `${formatNumber(stats.totalThreads)} threads`, - `${formatNumber(stats.totalMessages)} messages`, - `${formatNumber(stats.totalTokens)} tokens`, - `${stats.maxStreak} day streak`, - ``, - `get yours: npx ampcode-wrapped`, - ].join("\n"); - - const url = new URL("https://x.com/intent/tweet"); - url.searchParams.set("text", text); - return url.toString(); -} - -async function openUrl(url: string): Promise { - const platform = process.platform; - let command: string; - let args: string[]; - - if (platform === "darwin") { - command = "open"; - args = [url]; - } else if (platform === "win32") { - command = "cmd"; - args = ["/c", "start", "", url]; - } else { - command = "xdg-open"; - args = [url]; - } - - try { - const proc = Bun.spawn([command, ...args], { - stdout: "ignore", - stderr: "ignore", - }); - await proc.exited; - return proc.exitCode === 0; - } catch { - return false; - } -} - -main().catch((error) => { - console.error("Unexpected error:", error); - process.exit(1); -});