claude-attachment-client.mjs 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287
  1. import { createHash } from "node:crypto";
  2. import { open, mkdir, readdir, stat, writeFile } from "node:fs/promises";
  3. import { homedir } from "node:os";
  4. import { basename, dirname, join, resolve } from "node:path";
  5. import { fileURLToPath } from "node:url";
  6. const DEFAULT_SKILL_NAME = "fmode-image-set";
  7. const DEFAULT_MAX_AGE_MS = 30 * 60 * 1000;
  8. const MAX_SESSION_TAIL_BYTES = 8 * 1024 * 1024;
  9. const MAX_IMAGE_BYTES = 20 * 1024 * 1024;
  10. const mediaTypes = new Map([
  11. ["image/jpeg", { extension: ".jpg", signature: (buffer) => buffer[0] === 0xff && buffer[1] === 0xd8 && buffer[2] === 0xff }],
  12. ["image/png", { extension: ".png", signature: (buffer) => buffer.subarray(0, 8).equals(Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a])) }],
  13. ["image/webp", { extension: ".webp", signature: (buffer) => buffer.subarray(0, 4).toString("ascii") === "RIFF" && buffer.subarray(8, 12).toString("ascii") === "WEBP" }],
  14. ["image/gif", { extension: ".gif", signature: (buffer) => ["GIF87a", "GIF89a"].includes(buffer.subarray(0, 6).toString("ascii")) }],
  15. ]);
  16. export class AttachmentResolutionError extends Error {
  17. constructor(code, message) {
  18. super(message);
  19. this.name = "AttachmentResolutionError";
  20. this.code = code;
  21. }
  22. }
  23. function normalizePath(value) {
  24. return resolve(value).replaceAll("\\", "/").toLowerCase();
  25. }
  26. async function listSessionFiles(rootDirectory) {
  27. const files = [];
  28. const pending = [rootDirectory];
  29. while (pending.length > 0) {
  30. const directory = pending.pop();
  31. let entries;
  32. try {
  33. entries = await readdir(directory, { withFileTypes: true });
  34. } catch (error) {
  35. if (error?.code === "ENOENT") return [];
  36. throw error;
  37. }
  38. for (const entry of entries) {
  39. const path = join(directory, entry.name);
  40. if (entry.isDirectory()) pending.push(path);
  41. else if (entry.isFile() && entry.name.endsWith(".jsonl")) files.push(path);
  42. }
  43. }
  44. return files;
  45. }
  46. async function readSessionTail(file, knownStat = null) {
  47. const fileStat = knownStat ?? await stat(file);
  48. const start = Math.max(0, fileStat.size - MAX_SESSION_TAIL_BYTES);
  49. const length = fileStat.size - start;
  50. const handle = await open(file, "r");
  51. try {
  52. const buffer = Buffer.alloc(length);
  53. await handle.read(buffer, 0, length, start);
  54. let text = buffer.toString("utf8");
  55. if (start > 0) {
  56. const firstNewline = text.indexOf("\n");
  57. text = firstNewline >= 0 ? text.slice(firstNewline + 1) : "";
  58. }
  59. const records = [];
  60. for (const line of text.split(/\r?\n/)) {
  61. if (!line.trim()) continue;
  62. try {
  63. records.push(JSON.parse(line));
  64. } catch {
  65. // Ignore an incomplete trailing line while Claude Code is appending.
  66. }
  67. }
  68. return { records, fileStat };
  69. } finally {
  70. await handle.close();
  71. }
  72. }
  73. function contentParts(content) {
  74. if (Array.isArray(content)) return content;
  75. if (typeof content === "string") return [{ type: "text", text: content }];
  76. return [];
  77. }
  78. function latestSkillInvocation(records, skillName, expectedCwd) {
  79. const groups = new Map();
  80. records.forEach((record, index) => {
  81. if (record.type !== "user" || record.message?.role !== "user" || !record.promptId) return;
  82. const group = groups.get(record.promptId) ?? {
  83. promptId: record.promptId,
  84. timestamp: record.timestamp,
  85. cwd: record.cwd,
  86. lastIndex: index,
  87. texts: [],
  88. images: [],
  89. };
  90. group.timestamp = record.timestamp || group.timestamp;
  91. group.cwd = record.cwd || group.cwd;
  92. group.lastIndex = index;
  93. for (const part of contentParts(record.message.content)) {
  94. if (part?.type === "text" && typeof part.text === "string") group.texts.push(part.text);
  95. if (part?.type === "image") group.images.push(part);
  96. }
  97. groups.set(record.promptId, group);
  98. });
  99. const expected = normalizePath(expectedCwd);
  100. const commandMarker = `<command-name>/${skillName}</command-name>`;
  101. const skillPathMarkers = [
  102. `/.claude/skills/${skillName}`,
  103. `\\.claude\\skills\\${skillName}`,
  104. ];
  105. return [...groups.values()]
  106. .filter((group) => group.cwd && normalizePath(group.cwd) === expected)
  107. .filter((group) => {
  108. const text = group.texts.join("\n");
  109. return text.includes(commandMarker) || skillPathMarkers.some((marker) => text.toLowerCase().includes(marker.toLowerCase()));
  110. })
  111. .sort((a, b) => {
  112. const timeDifference = Date.parse(b.timestamp || 0) - Date.parse(a.timestamp || 0);
  113. return timeDifference || b.lastIndex - a.lastIndex;
  114. })[0] ?? null;
  115. }
  116. function decodeImage(part) {
  117. const source = part?.source;
  118. if (source?.type !== "base64" || typeof source.data !== "string") {
  119. throw new AttachmentResolutionError("ATTACHMENT_FORMAT_UNSUPPORTED", "The current Claude attachment is not an embedded base64 image");
  120. }
  121. const media = mediaTypes.get(source.media_type);
  122. if (!media) {
  123. throw new AttachmentResolutionError("ATTACHMENT_TYPE_UNSUPPORTED", `Unsupported attachment media type: ${source.media_type || "unknown"}`);
  124. }
  125. if (!/^[A-Za-z0-9+/]*={0,2}$/.test(source.data) || source.data.length % 4 !== 0) {
  126. throw new AttachmentResolutionError("ATTACHMENT_DATA_INVALID", "The current Claude attachment contains invalid base64 data");
  127. }
  128. const buffer = Buffer.from(source.data, "base64");
  129. if (buffer.length === 0 || buffer.length > MAX_IMAGE_BYTES || !media.signature(buffer)) {
  130. throw new AttachmentResolutionError("ATTACHMENT_DATA_INVALID", "The current Claude attachment is empty, too large, or does not match its declared image type");
  131. }
  132. return { buffer, mediaType: source.media_type, extension: media.extension };
  133. }
  134. export async function inspectLatestClaudeInvocation({
  135. cwd = process.cwd(),
  136. claudeConfigDirectory = process.env.CLAUDE_CONFIG_DIR || join(homedir(), ".claude"),
  137. sessionId = process.env.CLAUDE_CODE_SESSION_ID || "",
  138. skillName = DEFAULT_SKILL_NAME,
  139. maxAgeMs = DEFAULT_MAX_AGE_MS,
  140. now = Date.now(),
  141. } = {}) {
  142. const resolvedClaudeConfigDirectory = claudeConfigDirectory || join(homedir(), ".claude");
  143. const projectsDirectory = join(resolvedClaudeConfigDirectory, "projects");
  144. let files = await listSessionFiles(projectsDirectory);
  145. if (sessionId) {
  146. files = files.filter((file) => file.toLowerCase().endsWith(`${sessionId.toLowerCase()}.jsonl`));
  147. if (files.length === 0) {
  148. throw new AttachmentResolutionError("CLAUDE_SESSION_NOT_FOUND", `Claude session ${sessionId} was not found`);
  149. }
  150. }
  151. const candidates = [];
  152. for (const file of files) {
  153. let parsed;
  154. try {
  155. const fileStat = await stat(file);
  156. if (!sessionId && now - fileStat.mtimeMs > maxAgeMs) continue;
  157. parsed = await readSessionTail(file, fileStat);
  158. } catch {
  159. continue;
  160. }
  161. const invocation = latestSkillInvocation(parsed.records, skillName, cwd);
  162. if (!invocation) continue;
  163. candidates.push({
  164. file,
  165. sessionId: basename(file, ".jsonl"),
  166. fileMtimeMs: parsed.fileStat.mtimeMs,
  167. ...invocation,
  168. });
  169. }
  170. if (candidates.length === 0) {
  171. throw new AttachmentResolutionError("CLAUDE_ATTACHMENT_NOT_FOUND", "No recent Claude Code skill invocation was found for the current project");
  172. }
  173. candidates.sort((a, b) => {
  174. const timeDifference = Date.parse(b.timestamp || 0) - Date.parse(a.timestamp || 0);
  175. return timeDifference || b.fileMtimeMs - a.fileMtimeMs;
  176. });
  177. const current = candidates[0];
  178. const promptTime = Date.parse(current.timestamp || 0);
  179. if (!Number.isFinite(promptTime) || now - promptTime > maxAgeMs) {
  180. throw new AttachmentResolutionError("CLAUDE_ATTACHMENT_EXPIRED", "The latest Claude Code skill invocation is too old");
  181. }
  182. return {
  183. prompt_id: current.promptId,
  184. session_id: current.sessionId,
  185. captured_at: current.timestamp,
  186. image_count: current.images.length,
  187. current,
  188. };
  189. }
  190. export async function extractLatestClaudeAttachment({
  191. cwd = process.cwd(),
  192. outputDirectory,
  193. claudeConfigDirectory = process.env.CLAUDE_CONFIG_DIR || join(homedir(), ".claude"),
  194. sessionId = process.env.CLAUDE_CODE_SESSION_ID || "",
  195. skillName = DEFAULT_SKILL_NAME,
  196. maxAgeMs = DEFAULT_MAX_AGE_MS,
  197. now = Date.now(),
  198. } = {}) {
  199. if (!outputDirectory) throw new AttachmentResolutionError("ATTACHMENT_OUTPUT_REQUIRED", "An attachment output directory is required");
  200. const invocation = await inspectLatestClaudeInvocation({
  201. cwd,
  202. claudeConfigDirectory,
  203. sessionId,
  204. skillName,
  205. maxAgeMs,
  206. now,
  207. });
  208. const current = invocation.current;
  209. if (current.images.length === 0) {
  210. throw new AttachmentResolutionError("CLAUDE_ATTACHMENT_NOT_FOUND", "The current skill invocation does not contain an image attachment");
  211. }
  212. if (current.images.length > 1) {
  213. throw new AttachmentResolutionError("CLAUDE_ATTACHMENT_AMBIGUOUS", "The current skill invocation contains more than one image attachment");
  214. }
  215. const decoded = decodeImage(current.images[0]);
  216. const sha256 = createHash("sha256").update(decoded.buffer).digest("hex");
  217. const outputFile = resolve(outputDirectory, `reference-${sha256.slice(0, 16)}${decoded.extension}`);
  218. await mkdir(dirname(outputFile), { recursive: true });
  219. await writeFile(outputFile, decoded.buffer);
  220. return {
  221. schema_version: "0.1",
  222. status: "complete",
  223. source: "claude_code_attachment",
  224. file_path: outputFile,
  225. media_type: decoded.mediaType,
  226. sha256,
  227. prompt_id: current.promptId,
  228. session_id: current.sessionId,
  229. captured_at: current.timestamp,
  230. };
  231. }
  232. function option(args, name, fallback = "") {
  233. const index = args.indexOf(name);
  234. return index >= 0 ? args[index + 1] : fallback;
  235. }
  236. async function runCli() {
  237. const args = process.argv.slice(2);
  238. const outputDirectory = option(args, "--output-dir");
  239. const resultFile = option(args, "--result");
  240. const maxAgeMinutes = Number(option(args, "--max-age-minutes", "30"));
  241. let result;
  242. try {
  243. result = await extractLatestClaudeAttachment({
  244. cwd: option(args, "--cwd", process.cwd()),
  245. outputDirectory,
  246. claudeConfigDirectory: option(args, "--claude-config-dir", process.env.CLAUDE_CONFIG_DIR || join(homedir(), ".claude")),
  247. sessionId: option(args, "--session-id", process.env.CLAUDE_CODE_SESSION_ID || ""),
  248. skillName: option(args, "--skill", DEFAULT_SKILL_NAME),
  249. maxAgeMs: Number.isFinite(maxAgeMinutes) && maxAgeMinutes > 0 ? maxAgeMinutes * 60 * 1000 : DEFAULT_MAX_AGE_MS,
  250. });
  251. } catch (error) {
  252. result = {
  253. schema_version: "0.1",
  254. status: "blocked",
  255. error: {
  256. code: error?.code || "CLAUDE_ATTACHMENT_RESOLUTION_FAILED",
  257. message: error?.message || "Claude attachment resolution failed",
  258. },
  259. };
  260. }
  261. if (resultFile) {
  262. const absoluteResult = resolve(resultFile);
  263. await mkdir(dirname(absoluteResult), { recursive: true });
  264. await writeFile(absoluteResult, `${JSON.stringify(result, null, 2)}\n`, "utf8");
  265. }
  266. console.log(JSON.stringify(result, null, 2));
  267. }
  268. const invokedFile = process.argv[1] ? resolve(process.argv[1]) : "";
  269. if (invokedFile && normalizePath(invokedFile) === normalizePath(fileURLToPath(import.meta.url))) {
  270. await runCli();
  271. }