| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287 |
- 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 = `<command-name>/${skillName}</command-name>`;
- 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();
- }
|