import type { PluginAPI, ToolResultEvent } from "@ampcode/plugin"; import { appendFileSync, chmodSync, copyFileSync, existsSync, lstatSync, mkdirSync, readFileSync, renameSync, symlinkSync, unlinkSync, writeFileSync, } from "node:fs"; import { homedir, release } from "node:os"; import { basename, dirname, join } from "node:path"; const VERSION = "1.0.0"; const PLUGIN_NAME = "amp-cli-wakatime"; const GITHUB_RELEASES_URL = "https://api.github.com/repos/wakatime/wakatime-cli/releases/latest"; const GITHUB_DOWNLOAD_URL = "https://github.com/wakatime/wakatime-cli/releases/latest/download"; const USER_AGENT = "github.com/wakatime/amp-cli-wakatime"; let pluginLogger: PluginAPI["logger"] | undefined; let cliQueue: Promise = Promise.resolve(); let heartbeatQueue: Promise = Promise.resolve(); export default function (amp: PluginAPI) { pluginLogger = amp.logger; const projectFolder = process.cwd(); amp.on("session.start", () => { void getWakatimeCli(true).catch((error) => logException("WARN", error)); }); amp.on("agent.start", () => { queueAiHeartbeatSync(projectFolder); return {}; }); amp.on("tool.result", (event) => { if (!isSuccessfulFileEdit(amp, event)) return; queueAiHeartbeatSync(projectFolder); }); } function isSuccessfulFileEdit(amp: PluginAPI, event: ToolResultEvent): boolean { if (event.status !== "done") return false; const files = amp.helpers.filesModifiedByToolCall(event); return files !== null && files.length > 0; } function queueAiHeartbeatSync(projectFolder: string) { heartbeatQueue = heartbeatQueue.then( () => syncAiHeartbeats(projectFolder), () => syncAiHeartbeats(projectFolder), ); } async function syncAiHeartbeats(projectFolder: string) { try { const cliPath = await getWakatimeCli(false); const ampVersion = cleanVersion( process.env.AMP_CLI_VERSION || process.env.AMP_VERSION || process.env.SRC_AMP_VERSION || "", ); const args = buildHeartbeatArgs(projectFolder, ampVersion); log( "DEBUG", `Syncing AI heartbeats from ${projectFolder || "(unknown project)"}`, ); const result = await runProcess(cliPath, args, 120_000); const output = `${result.stdout}${result.stderr}`.trim(); if (output) log("WARN", output); } catch (error) { logException("WARN", error); } } function buildHeartbeatArgs( projectFolder: string, ampVersion: string, ): string[] { const plugin = `amp-cli/${ampVersion || "unknown"} ${PLUGIN_NAME}/${VERSION}`; const args = ["--sync-ai-heartbeats", "--plugin", plugin]; if (projectFolder) args.push("--project-folder", projectFolder); return args; } function getWakatimeCli(checkLatest: boolean): Promise { let result: string | undefined; const operation = cliQueue.then(async () => { result = await ensureWakatimeCli(checkLatest); }); cliQueue = operation.then( () => undefined, () => undefined, ); return operation.then(() => result as string); } async function ensureWakatimeCli(checkLatest: boolean): Promise { const cliPath = getCliLocation(); mkdirSync(getWakatimeDir(), { recursive: true }); if (!existsSync(cliPath)) { await installCli(); return cliPath; } let currentVersion: string; try { currentVersion = await getCurrentCliVersion(cliPath); log("DEBUG", `Current wakatime-cli version is ${currentVersion}`); } catch { await installCli(); return cliPath; } if (checkLatest && !(await isCliLatest(currentVersion))) { await installCli(); } else { ensureCliAlias(cliPath); } return cliPath; } async function getCurrentCliVersion(cliPath: string): Promise { const result = await runProcess(cliPath, ["--version"], 10_000); return `${result.stdout}${result.stderr}`.trim(); } async function isCliLatest(currentVersion: string): Promise { if (currentVersion === "") { log("DEBUG", "Skipping wakatime-cli updates for ."); return true; } const legacyTag = getLegacyReleaseTag(); if (legacyTag) return currentVersion === legacyTag; const latestVersion = await getLatestCliVersion(); if (!latestVersion) return true; if (currentVersion === latestVersion) { log("DEBUG", "wakatime-cli is up to date"); return true; } log( "DEBUG", `Found wakatime-cli update ${currentVersion} -> ${latestVersion}`, ); return false; } async function getLatestCliVersion(): Promise { log("DEBUG", `Checking ${GITHUB_RELEASES_URL}`); try { const response = await fetchWithTimeout(GITHUB_RELEASES_URL, 15_000); if (!response.ok) { log("WARN", `GitHub releases API returned HTTP ${response.status}`); return ""; } const release = (await response.json()) as { tag_name?: unknown }; const version = typeof release.tag_name === "string" ? release.tag_name : ""; if (version) log("DEBUG", `Latest wakatime-cli version is ${version}`); return version; } catch (error) { logException("WARN", error); return ""; } } async function installCli() { const cliPath = getCliLocation(); const downloadURL = getCliDownloadURL(); const archiveName = basename(new URL(downloadURL).pathname); const expectedBinary = archiveName.slice(0, -".zip".length) + (isWindows() ? ".exe" : ""); log("DEBUG", `Downloading wakatime-cli from ${downloadURL}`); const response = await fetchWithTimeout(downloadURL, 120_000); if (!response.ok) { throw new Error(`wakatime-cli download returned HTTP ${response.status}`); } const archive = new Uint8Array(await response.arrayBuffer()); const binary = extractZipFile(archive, expectedBinary); const suffix = `${process.pid}-${crypto.randomUUID()}`; const temporaryPath = `${cliPath}.tmp-${suffix}`; const backupPath = `${cliPath}.backup-${suffix}`; let backedUp = false; writeFileSync(temporaryPath, binary, { mode: 0o755 }); try { if (existsSync(cliPath) || isSymlink(cliPath)) { renameSync(cliPath, backupPath); backedUp = true; } renameSync(temporaryPath, cliPath); if (!isWindows()) chmodSync(cliPath, 0o755); ensureCliAlias(cliPath); if (backedUp) unlinkIfPresent(backupPath); } catch (error) { unlinkIfPresent(cliPath); if (backedUp) renameSync(backupPath, cliPath); throw error; } finally { unlinkIfPresent(temporaryPath); } } function ensureCliAlias(cliPath: string) { const alias = join( getWakatimeDir(), `wakatime-cli${isWindows() ? ".exe" : ""}`, ); try { if (isSymlink(alias) && !isWindows()) return; unlinkIfPresent(alias); if (isWindows()) { copyFileSync(cliPath, alias); } else { symlinkSync(cliPath, alias); } } catch (error) { logException("WARN", error); try { unlinkIfPresent(alias); copyFileSync(cliPath, alias); if (!isWindows()) chmodSync(alias, 0o755); } catch (copyError) { logException("WARN", copyError); } } } function getCliLocation(): string { const extension = isWindows() ? ".exe" : ""; return join( getWakatimeDir(), `wakatime-cli-${getOSName()}-${getArchitecture()}${extension}`, ); } function getCliDownloadURL(): string { const platform = `${getOSName()}-${getArchitecture()}`; const supported = new Set([ "darwin-amd64", "darwin-arm64", "linux-386", "linux-amd64", "linux-arm", "linux-arm64", "windows-386", "windows-amd64", "windows-arm64", ]); if (!supported.has(platform)) { reportMissingPlatformSupport(); throw new Error(`wakatime-cli does not publish a binary for ${platform}`); } const legacyTag = getLegacyReleaseTag(); if (legacyTag) { return `https://github.com/wakatime/wakatime-cli/releases/download/${legacyTag}/wakatime-cli-${platform}.zip`; } return `${GITHUB_DOWNLOAD_URL}/wakatime-cli-${platform}.zip`; } function reportMissingPlatformSupport() { const url = new URL("https://api.wakatime.com/api/v1/cli-missing"); url.searchParams.set("osname", getOSName()); url.searchParams.set("architecture", getArchitecture()); url.searchParams.set("plugin", "amp-cli"); void fetchWithTimeout(url.toString(), 5_000).catch(() => undefined); } function getLegacyReleaseTag(): string | undefined { if (getOSName() !== "darwin") return undefined; return compareVersions(release(), "17.0.0") < 0 ? "v1.39.1-alpha.1" : undefined; } function getArchitecture(architecture = process.arch): string { if (architecture === "ia32" || architecture.includes("32")) return "386"; if (architecture === "x64") return "amd64"; return architecture; } function getOSName(platform = process.platform): string { return platform === "win32" ? "windows" : platform; } function isWindows(): boolean { return process.platform === "win32"; } async function fetchWithTimeout( url: string, timeoutMs: number, ): Promise { const controller = new AbortController(); const timeout = setTimeout(() => controller.abort(), timeoutMs); const proxy = getSetting("settings", "proxy"); const noSSLVerify = getSetting("settings", "no_ssl_verify") === "true"; const options: RequestInit & { proxy?: string; tls?: { rejectUnauthorized: boolean }; } = { headers: { "User-Agent": USER_AGENT }, redirect: "follow", signal: controller.signal, }; if (proxy) options.proxy = proxy; if (noSSLVerify) options.tls = { rejectUnauthorized: false }; try { return await fetch(url, options); } finally { clearTimeout(timeout); } } function extractZipFile( archive: Uint8Array, expectedFileName: string, ): Uint8Array { const view = new DataView( archive.buffer, archive.byteOffset, archive.byteLength, ); const eocdOffset = findEndOfCentralDirectory(view); if (eocdOffset < 0) { throw new Error("Invalid wakatime-cli ZIP: missing central directory"); } const entryCount = readUint16(view, eocdOffset + 10); let offset = readUint32(view, eocdOffset + 16); const decoder = new TextDecoder(); for (let index = 0; index < entryCount; index += 1) { assertRange(view, offset, 46); if (readUint32(view, offset) !== 0x02014b50) { throw new Error("Invalid wakatime-cli ZIP: bad central directory header"); } const flags = readUint16(view, offset + 8); const method = readUint16(view, offset + 10); const checksum = readUint32(view, offset + 16); const compressedSize = readUint32(view, offset + 20); const uncompressedSize = readUint32(view, offset + 24); const fileNameLength = readUint16(view, offset + 28); const extraLength = readUint16(view, offset + 30); const commentLength = readUint16(view, offset + 32); const localHeaderOffset = readUint32(view, offset + 42); assertRange( view, offset + 46, fileNameLength + extraLength + commentLength, ); const fileName = decoder.decode( archive.subarray(offset + 46, offset + 46 + fileNameLength), ); if (basename(fileName.replaceAll("\\", "/")) === expectedFileName) { if ((flags & 0x1) !== 0) { throw new Error("Encrypted wakatime-cli ZIP entries are unsupported"); } return extractZipEntry( archive, view, localHeaderOffset, method, compressedSize, uncompressedSize, checksum, ); } offset += 46 + fileNameLength + extraLength + commentLength; } throw new Error( `Invalid wakatime-cli ZIP: ${expectedFileName} was not found`, ); } function extractZipEntry( archive: Uint8Array, view: DataView, localHeaderOffset: number, method: number, compressedSize: number, uncompressedSize: number, expectedChecksum: number, ): Uint8Array { assertRange(view, localHeaderOffset, 30); if (readUint32(view, localHeaderOffset) !== 0x04034b50) { throw new Error("Invalid wakatime-cli ZIP: bad local file header"); } const fileNameLength = readUint16(view, localHeaderOffset + 26); const extraLength = readUint16(view, localHeaderOffset + 28); const dataStart = localHeaderOffset + 30 + fileNameLength + extraLength; assertRange(view, dataStart, compressedSize); const compressed = archive.subarray(dataStart, dataStart + compressedSize); let binary: Uint8Array; if (method === 0) { binary = compressed.slice(); } else if (method === 8) { binary = Bun.inflateSync(compressed, { windowBits: -15 }); } else { throw new Error(`Unsupported ZIP compression method ${method}`); } if (binary.byteLength !== uncompressedSize) { throw new Error( "Invalid wakatime-cli ZIP: uncompressed size does not match", ); } if (crc32(binary) !== expectedChecksum) { throw new Error("Invalid wakatime-cli ZIP: checksum does not match"); } return binary; } function findEndOfCentralDirectory(view: DataView): number { const lowerBound = Math.max(0, view.byteLength - 65_557); for (let offset = view.byteLength - 22; offset >= lowerBound; offset -= 1) { if (readUint32(view, offset) === 0x06054b50) return offset; } return -1; } function assertRange(view: DataView, offset: number, length: number) { if (offset < 0 || length < 0 || offset + length > view.byteLength) { throw new Error("Invalid wakatime-cli ZIP: entry exceeds archive bounds"); } } function readUint16(view: DataView, offset: number): number { assertRange(view, offset, 2); return view.getUint16(offset, true); } function readUint32(view: DataView, offset: number): number { assertRange(view, offset, 4); return view.getUint32(offset, true); } function crc32(bytes: Uint8Array): number { let crc = 0xffffffff; for (const byte of bytes) { crc ^= byte; for (let bit = 0; bit < 8; bit += 1) { crc = (crc >>> 1) ^ (crc & 1 ? 0xedb88320 : 0); } } return (crc ^ 0xffffffff) >>> 0; } async function runProcess( command: string, args: string[], timeoutMs: number, ): Promise<{ stdout: string; stderr: string }> { const processHandle = Bun.spawn([command, ...args], { env: process.env, stderr: "pipe", stdout: "pipe", }); let timedOut = false; const timeout = setTimeout(() => { timedOut = true; processHandle.kill(); }, timeoutMs); try { const [exitCode, stdout, stderr] = await Promise.all([ processHandle.exited, new Response(processHandle.stdout).text(), new Response(processHandle.stderr).text(), ]); if (timedOut) { throw new Error(`${basename(command)} timed out after ${timeoutMs}ms`); } if (exitCode !== 0) { const detail = `${stdout}${stderr}`.trim(); throw new Error( `${basename(command)} exited with status ${exitCode}${ detail ? `: ${detail}` : "" }`, ); } return { stdout, stderr }; } finally { clearTimeout(timeout); } } function getSetting(section: string, key: string): string | undefined { try { return parseSetting(readFileSync(getConfigFile(), "utf8"), section, key); } catch { return undefined; } } function parseSetting( content: string, section: string, key: string, ): string | undefined { let currentSection = ""; for (const line of content.split("\n")) { const trimmed = line.trim(); if (!trimmed || trimmed.startsWith(";") || trimmed.startsWith("#")) { continue; } if (trimmed.startsWith("[") && trimmed.endsWith("]")) { currentSection = trimmed.slice(1, -1).toLowerCase(); continue; } if (currentSection !== section.toLowerCase()) continue; const separator = line.indexOf("="); if ( separator < 0 || line.slice(0, separator).trim().toLowerCase() !== key.toLowerCase() ) continue; return line.slice(separator + 1).trim().replaceAll("\0", ""); } return undefined; } function getConfigFile(): string { return join(getHomeDirectory(), ".wakatime.cfg"); } function getWakatimeDir(): string { return join(getHomeDirectory(), ".wakatime"); } function getHomeDirectory(): string { return ( cleanPath(process.env.WAKATIME_HOME) || cleanPath(isWindows() ? process.env.USERPROFILE : process.env.HOME) || homedir() || process.cwd() ); } function cleanPath(value: string | undefined): string | undefined { if (!value?.trim()) return undefined; const path = value.trim(); if (path.startsWith("${") && path.endsWith("}")) return undefined; return path; } function cleanVersion(value: string): string { const match = value.match(/\d+\.\d+\.\d+(?:[-+][A-Za-z0-9.-]+)?/); return match?.[0] || ""; } function compareVersions(left: string, right: string): number { const leftParts = left.split(/[.-]/).map((part) => Number.parseInt(part, 10) || 0 ); const rightParts = right.split(/[.-]/).map((part) => Number.parseInt(part, 10) || 0 ); const length = Math.max(leftParts.length, rightParts.length); for (let index = 0; index < length; index += 1) { if ((leftParts[index] || 0) < (rightParts[index] || 0)) return -1; if ((leftParts[index] || 0) > (rightParts[index] || 0)) return 1; } return 0; } function isSymlink(path: string): boolean { try { return lstatSync(path).isSymbolicLink(); } catch { return false; } } function unlinkIfPresent(path: string) { try { unlinkSync(path); } catch (error) { if ((error as { code?: string }).code !== "ENOENT") throw error; } } function log(level: "DEBUG" | "WARN", message: string) { if (level === "DEBUG" && getSetting("settings", "debug") !== "true") return; try { const logFile = join(getWakatimeDir(), "amp-cli.log"); mkdirSync(dirname(logFile), { recursive: true }); appendFileSync( logFile, `[${new Date().toISOString()}][${level}] ${message}\n`, ); } catch { // WakaTime must never interfere with Amp when logging is unavailable. } pluginLogger?.log(`[WakaTime][${level}] ${message}`); } function logException(level: "DEBUG" | "WARN", error: unknown) { log(level, error instanceof Error ? error.message : String(error)); } export const __testing = { buildHeartbeatArgs, compareVersions, crc32, ensureWakatimeCli, extractZipFile, getArchitecture, getOSName, parseSetting, };