import { createHash } from "node:crypto"; import { open, mkdir, readdir, stat, writeFile } from "node:fs/promises"; import { homedir } from "node:os"; import { basename, dirname, join, resolve } from "node:path"; import { fileURLToPath } from "node:url"; const DEFAULT_SKILL_NAME = "fmode-image-set"; const DEFAULT_MAX_AGE_MS = 30 * 60 * 1000; const MAX_SESSION_TAIL_BYTES = 8 * 1024 * 1024; const MAX_IMAGE_BYTES = 20 * 1024 * 1024; const mediaTypes = new Map([ ["image/jpeg", { extension: ".jpg", signature: (buffer) => buffer[0] === 0xff && buffer[1] === 0xd8 && buffer[2] === 0xff }], ["image/png", { extension: ".png", signature: (buffer) => buffer.subarray(0, 8).equals(Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a])) }], ["image/webp", { extension: ".webp", signature: (buffer) => buffer.subarray(0, 4).toString("ascii") === "RIFF" && buffer.subarray(8, 12).toString("ascii") === "WEBP" }], ["image/gif", { extension: ".gif", signature: (buffer) => ["GIF87a", "GIF89a"].includes(buffer.subarray(0, 6).toString("ascii")) }], ]); export class AttachmentResolutionError extends Error { constructor(code, message) { super(message); this.name = "AttachmentResolutionError"; this.code = code; } } function normalizePath(value) { return resolve(value).replaceAll("\\", "/").toLowerCase(); } async function listSessionFiles(rootDirectory) { const files = []; const pending = [rootDirectory]; while (pending.length > 0) { const directory = pending.pop(); let entries; try { entries = await readdir(directory, { withFileTypes: true }); } catch (error) { if (error?.code === "ENOENT") return []; throw error; } for (const entry of entries) { const path = join(directory, entry.name); if (entry.isDirectory()) pending.push(path); else if (entry.isFile() && entry.name.endsWith(".jsonl")) files.push(path); } } return files; } async function readSessionTail(file, knownStat = null) { const fileStat = knownStat ?? await stat(file); const start = Math.max(0, fileStat.size - MAX_SESSION_TAIL_BYTES); const length = fileStat.size - start; const handle = await open(file, "r"); try { const buffer = Buffer.alloc(length); await handle.read(buffer, 0, length, start); let text = buffer.toString("utf8"); if (start > 0) { const firstNewline = text.indexOf("\n"); text = firstNewline >= 0 ? text.slice(firstNewline + 1) : ""; } const records = []; for (const line of text.split(/\r?\n/)) { if (!line.trim()) continue; try { records.push(JSON.parse(line)); } catch { // Ignore an incomplete trailing line while Claude Code is appending. } } return { records, fileStat }; } finally { await handle.close(); } } function contentParts(content) { if (Array.isArray(content)) return content; if (typeof content === "string") return [{ type: "text", text: content }]; return []; } function latestSkillInvocation(records, skillName, expectedCwd) { const groups = new Map(); records.forEach((record, index) => { if (record.type !== "user" || record.message?.role !== "user" || !record.promptId) return; const group = groups.get(record.promptId) ?? { promptId: record.promptId, timestamp: record.timestamp, cwd: record.cwd, lastIndex: index, texts: [], images: [], }; group.timestamp = record.timestamp || group.timestamp; group.cwd = record.cwd || group.cwd; group.lastIndex = index; for (const part of contentParts(record.message.content)) { if (part?.type === "text" && typeof part.text === "string") group.texts.push(part.text); if (part?.type === "image") group.images.push(part); } groups.set(record.promptId, group); }); const expected = normalizePath(expectedCwd); const commandMarker = `/${skillName}`; const skillPathMarkers = [ `/.claude/skills/${skillName}`, `\\.claude\\skills\\${skillName}`, ]; return [...groups.values()] .filter((group) => group.cwd && normalizePath(group.cwd) === expected) .filter((group) => { const text = group.texts.join("\n"); return text.includes(commandMarker) || skillPathMarkers.some((marker) => text.toLowerCase().includes(marker.toLowerCase())); }) .sort((a, b) => { const timeDifference = Date.parse(b.timestamp || 0) - Date.parse(a.timestamp || 0); return timeDifference || b.lastIndex - a.lastIndex; })[0] ?? null; } function decodeImage(part) { const source = part?.source; if (source?.type !== "base64" || typeof source.data !== "string") { throw new AttachmentResolutionError("ATTACHMENT_FORMAT_UNSUPPORTED", "The current Claude attachment is not an embedded base64 image"); } const media = mediaTypes.get(source.media_type); if (!media) { throw new AttachmentResolutionError("ATTACHMENT_TYPE_UNSUPPORTED", `Unsupported attachment media type: ${source.media_type || "unknown"}`); } if (!/^[A-Za-z0-9+/]*={0,2}$/.test(source.data) || source.data.length % 4 !== 0) { throw new AttachmentResolutionError("ATTACHMENT_DATA_INVALID", "The current Claude attachment contains invalid base64 data"); } const buffer = Buffer.from(source.data, "base64"); if (buffer.length === 0 || buffer.length > MAX_IMAGE_BYTES || !media.signature(buffer)) { throw new AttachmentResolutionError("ATTACHMENT_DATA_INVALID", "The current Claude attachment is empty, too large, or does not match its declared image type"); } return { buffer, mediaType: source.media_type, extension: media.extension }; } export async function inspectLatestClaudeInvocation({ cwd = process.cwd(), claudeConfigDirectory = process.env.CLAUDE_CONFIG_DIR || join(homedir(), ".claude"), sessionId = process.env.CLAUDE_CODE_SESSION_ID || "", skillName = DEFAULT_SKILL_NAME, maxAgeMs = DEFAULT_MAX_AGE_MS, now = Date.now(), } = {}) { const resolvedClaudeConfigDirectory = claudeConfigDirectory || join(homedir(), ".claude"); const projectsDirectory = join(resolvedClaudeConfigDirectory, "projects"); let files = await listSessionFiles(projectsDirectory); if (sessionId) { files = files.filter((file) => file.toLowerCase().endsWith(`${sessionId.toLowerCase()}.jsonl`)); if (files.length === 0) { throw new AttachmentResolutionError("CLAUDE_SESSION_NOT_FOUND", `Claude session ${sessionId} was not found`); } } const candidates = []; for (const file of files) { let parsed; try { const fileStat = await stat(file); if (!sessionId && now - fileStat.mtimeMs > maxAgeMs) continue; parsed = await readSessionTail(file, fileStat); } catch { continue; } const invocation = latestSkillInvocation(parsed.records, skillName, cwd); if (!invocation) continue; candidates.push({ file, sessionId: basename(file, ".jsonl"), fileMtimeMs: parsed.fileStat.mtimeMs, ...invocation, }); } if (candidates.length === 0) { throw new AttachmentResolutionError("CLAUDE_ATTACHMENT_NOT_FOUND", "No recent Claude Code skill invocation was found for the current project"); } candidates.sort((a, b) => { const timeDifference = Date.parse(b.timestamp || 0) - Date.parse(a.timestamp || 0); return timeDifference || b.fileMtimeMs - a.fileMtimeMs; }); const current = candidates[0]; const promptTime = Date.parse(current.timestamp || 0); if (!Number.isFinite(promptTime) || now - promptTime > maxAgeMs) { throw new AttachmentResolutionError("CLAUDE_ATTACHMENT_EXPIRED", "The latest Claude Code skill invocation is too old"); } return { prompt_id: current.promptId, session_id: current.sessionId, captured_at: current.timestamp, image_count: current.images.length, current, }; } export async function extractLatestClaudeAttachment({ cwd = process.cwd(), outputDirectory, claudeConfigDirectory = process.env.CLAUDE_CONFIG_DIR || join(homedir(), ".claude"), sessionId = process.env.CLAUDE_CODE_SESSION_ID || "", skillName = DEFAULT_SKILL_NAME, maxAgeMs = DEFAULT_MAX_AGE_MS, now = Date.now(), } = {}) { if (!outputDirectory) throw new AttachmentResolutionError("ATTACHMENT_OUTPUT_REQUIRED", "An attachment output directory is required"); const invocation = await inspectLatestClaudeInvocation({ cwd, claudeConfigDirectory, sessionId, skillName, maxAgeMs, now, }); const current = invocation.current; if (current.images.length === 0) { throw new AttachmentResolutionError("CLAUDE_ATTACHMENT_NOT_FOUND", "The current skill invocation does not contain an image attachment"); } if (current.images.length > 1) { throw new AttachmentResolutionError("CLAUDE_ATTACHMENT_AMBIGUOUS", "The current skill invocation contains more than one image attachment"); } const decoded = decodeImage(current.images[0]); const sha256 = createHash("sha256").update(decoded.buffer).digest("hex"); const outputFile = resolve(outputDirectory, `reference-${sha256.slice(0, 16)}${decoded.extension}`); await mkdir(dirname(outputFile), { recursive: true }); await writeFile(outputFile, decoded.buffer); return { schema_version: "0.1", status: "complete", source: "claude_code_attachment", file_path: outputFile, media_type: decoded.mediaType, sha256, prompt_id: current.promptId, session_id: current.sessionId, captured_at: current.timestamp, }; } function option(args, name, fallback = "") { const index = args.indexOf(name); return index >= 0 ? args[index + 1] : fallback; } async function runCli() { const args = process.argv.slice(2); const outputDirectory = option(args, "--output-dir"); const resultFile = option(args, "--result"); const maxAgeMinutes = Number(option(args, "--max-age-minutes", "30")); let result; try { result = await extractLatestClaudeAttachment({ cwd: option(args, "--cwd", process.cwd()), outputDirectory, claudeConfigDirectory: option(args, "--claude-config-dir", process.env.CLAUDE_CONFIG_DIR || join(homedir(), ".claude")), sessionId: option(args, "--session-id", process.env.CLAUDE_CODE_SESSION_ID || ""), skillName: option(args, "--skill", DEFAULT_SKILL_NAME), maxAgeMs: Number.isFinite(maxAgeMinutes) && maxAgeMinutes > 0 ? maxAgeMinutes * 60 * 1000 : DEFAULT_MAX_AGE_MS, }); } catch (error) { result = { schema_version: "0.1", status: "blocked", error: { code: error?.code || "CLAUDE_ATTACHMENT_RESOLUTION_FAILED", message: error?.message || "Claude attachment resolution failed", }, }; } if (resultFile) { const absoluteResult = resolve(resultFile); await mkdir(dirname(absoluteResult), { recursive: true }); await writeFile(absoluteResult, `${JSON.stringify(result, null, 2)}\n`, "utf8"); } console.log(JSON.stringify(result, null, 2)); } const invokedFile = process.argv[1] ? resolve(process.argv[1]) : ""; if (invokedFile && normalizePath(invokedFile) === normalizePath(fileURLToPath(import.meta.url))) { await runCli(); }