Skip to content

Instantly share code, notes, and snippets.

Show Gist options
  • Select an option

  • Save guersam/cdb595449b89cb2e1423ec2659dd36fd to your computer and use it in GitHub Desktop.

Select an option

Save guersam/cdb595449b89cb2e1423ec2659dd36fd to your computer and use it in GitHub Desktop.
TamperMonkey Claude Project Knowledge Tools
// ==UserScript==
// @name Claude Artifact Batch Add to Project
// @namespace https://claude.ai
// @version 1.2
// @description 특정 메시지의 아티팩트들을 일괄로 프로젝트에 추가 (실시간 추적)
// @match https://claude.ai/chat/*
// @grant none
// @run-at document-idle
// ==/UserScript==
(function () {
'use strict';
// ── 상수 ──────────────────────────────────────────
const BUTTON_ATTR = 'data-batch-add-btn';
const SCANNED_ATTR = 'data-batch-scanned-count'; // 마지막 스캔 시 아티팩트 수 기록
const FOLDER_PLUS_SVG =
'<svg xmlns="http://www.w3.org/2000/svg" width="1.1rem" height="1.1rem" ' +
'fill="currentColor" viewBox="0 0 256 256" style="flex-shrink:0">' +
'<path d="M216,72H131.31L104,44.69A15.86,15.86,0,0,0,92.69,40H40A16,16,0,0,0,' +
'24,56V200.62A15.4,15.4,0,0,0,39.38,216H216.89A15.13,15.13,0,0,0,232,200.89V88' +
'A16,16,0,0,0,216,72Zm0,128H40V56H92.69l28,28H216Zm-88-24a8,8,0,0,0,8-8V148h20' +
'a8,8,0,0,0,0-16H136V112a8,8,0,0,0-16,0v20H100a8,8,0,0,0,0,16h20v20A8,8,0,0,0,' +
'128,176Z"></path></svg>';
// ── 유틸리티 ──────────────────────────────────────
function getOrgId() {
return document.cookie.match(/lastActiveOrg=([^;]+)/)?.[1];
}
function getConvId() {
return location.pathname.match(/\/chat\/([^/?#]+)/)?.[1];
}
function getProjectId() {
const a = document.querySelector('a[href*="/project/"]');
return a?.href?.match(/\/project\/([^/?#]+)/)?.[1];
}
function getReactFiber(el) {
const k = Object.keys(el).find(k => k.startsWith('__reactFiber$'));
return k ? el[k] : null;
}
function getFileInfo(artifactEl) {
try {
let fiber = getReactFiber(artifactEl);
// fiber 트리를 여러 단계 탐색하여 file prop 찾기
for (let i = 0; i < 8 && fiber; i++) {
const file = fiber.memoizedProps?.file;
if (file?.name && file?.path) return { name: file.name, path: file.path };
fiber = fiber.return;
}
return null;
} catch {
return null;
}
}
// ── 스트리밍 상태 감지 ────────────────────────────
function isStreaming() {
// Claude가 응답 중일 때 나타나는 stop 버튼 감지
return !!(
document.querySelector('button[aria-label="Stop Response"]') ||
document.querySelector('button[aria-label="Stop"]') ||
document.querySelector('[data-testid="stop-button"]')
);
}
// ── API ───────────────────────────────────────────
async function downloadFile(orgId, convId, filePath) {
const url =
`/api/organizations/${orgId}/conversations/${convId}` +
`/wiggle/download-file?path=${encodeURIComponent(filePath)}`;
const r = await fetch(url, { credentials: 'same-origin' });
if (!r.ok) throw new Error(`Download ${r.status}: ${filePath}`);
return r.text();
}
async function addToProject(orgId, projectId, fileName, content) {
const url =
`/api/organizations/${orgId}/projects/${projectId}/docs`;
const r = await fetch(url, {
method: 'POST',
credentials: 'same-origin',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ file_name: fileName, content }),
});
if (!r.ok) throw new Error(`Add ${r.status}: ${fileName}`);
return r.json();
}
// ── 아티팩트 그룹 감지 (개선) ─────────────────────
function findArtifactGroups() {
const all = document.querySelectorAll(
'[role="button"][aria-label*="Open artifact"]'
);
const map = new Map(); // container → [artifactEl, ...]
const assigned = new Set(); // 중복 등록 방지
all.forEach(el => {
if (assigned.has(el)) return;
let c = el.parentElement;
for (let i = 0; i < 15 && c; i++, c = c.parentElement) {
// 메시지 블록 경계 탐색: 여러 아티팩트를 포함하는 가장 가까운 컨테이너
const siblings = c.querySelectorAll(
'[role="button"][aria-label*="Open artifact"]'
);
// 조건 1: 아티팩트가 2개 이상인 컨테이너
// 조건 2: 단일 아티팩트라도 메시지 블록 수준의 컨테이너 (py-2 또는 message 관련 클래스)
const isMessageBlock =
siblings.length > 1 ||
c.classList.contains('py-2') ||
c.getAttribute('data-testid')?.includes('message');
if (isMessageBlock) {
if (!map.has(c)) map.set(c, []);
const arr = map.get(c);
siblings.forEach(s => {
if (!assigned.has(s)) {
assigned.add(s);
arr.push(s);
}
});
break;
}
}
});
return map;
}
// ── 버튼 삽입 위치 찾기 (개선) ────────────────────
function findInsertionPoint(container) {
// 1순위: "Download all" 버튼 옆
const dlAll = Array.from(container.querySelectorAll('button'))
.find(b => b.textContent.trim() === 'Download all');
if (dlAll) return { parent: dlAll.parentElement, after: dlAll };
// 2순위: 아티팩트 목록 바로 다음의 flex 컨테이너 (action bar)
const artifacts = container.querySelectorAll(
'[role="button"][aria-label*="Open artifact"]'
);
if (artifacts.length > 0) {
const lastArtifact = artifacts[artifacts.length - 1];
// action bar를 찾기: 아티팩트 다음에 오는 버튼 그룹
let node = lastArtifact.parentElement;
for (let i = 0; i < 5 && node; i++, node = node.parentElement) {
const actionBar = node.querySelector('div[class*="flex"]');
if (actionBar && actionBar.querySelector('button')) {
return { parent: actionBar, after: null };
}
}
}
// 3순위: 컨테이너 끝에 추가
return { parent: container, after: null };
}
// ── UI (개선: 업데이트 지원) ───────────────────────
function applyBaseStyle(btn) {
Object.assign(btn.style, {
display: 'inline-flex',
alignItems: 'center',
gap: '4px',
height: '36px',
boxSizing: 'border-box',
margin: '0 0 0 8px',
padding: '8px 12px 8px 8px',
fontSize: '14px',
fontWeight: '500',
lineHeight: '19.6px',
color: 'rgb(180, 83, 9)',
background: 'transparent',
border: '0.8px solid rgba(180, 83, 9, 0.4)',
borderRadius: '8px',
cursor: 'pointer',
transition: 'all 0.15s',
});
}
function updateOrCreateButton(artifacts, container) {
const count = artifacts.length;
if (count < 1) return;
const existing = container.querySelector(`[${BUTTON_ATTR}]`);
const prevCount = existing
? parseInt(existing.getAttribute(SCANNED_ATTR) || '0', 10)
: 0;
// 이미 같은 수의 아티팩트로 버튼이 존재하면 스킵
if (existing && prevCount === count) return;
// 기존 버튼이 있지만 카운트가 달라졌으면 제거 후 재생성
if (existing) {
existing.remove();
}
const btn = document.createElement('button');
btn.setAttribute(BUTTON_ATTR, 'true');
btn.setAttribute(SCANNED_ATTR, String(count));
btn.innerHTML = `${FOLDER_PLUS_SVG} Add ${count} to Project`;
applyBaseStyle(btn);
btn.addEventListener('mouseenter', () => {
if (btn.disabled) return;
btn.style.background = '#fffbeb';
btn.style.borderColor = 'rgba(180, 83, 9, 0.6)';
});
btn.addEventListener('mouseleave', () => {
if (btn.disabled) return;
btn.style.background = 'transparent';
btn.style.borderColor = 'rgba(180, 83, 9, 0.4)';
});
btn.addEventListener('click', async () => {
const orgId = getOrgId();
const convId = getConvId();
const projectId = getProjectId();
if (!orgId || !convId || !projectId) {
alert('프로젝트 정보를 찾을 수 없습니다.\n이 채팅이 프로젝트에 속해 있는지 확인하세요.');
return;
}
// ★ 클릭 시점에 아티팩트를 다시 탐색하여 최신 fiber 정보 사용
const freshArtifacts = container.querySelectorAll(
'[role="button"][aria-label*="Open artifact"]'
);
const files = Array.from(freshArtifacts).map(getFileInfo).filter(Boolean);
if (!files.length) {
alert('아티팩트 파일 정보를 추출할 수 없습니다.\n잠시 후 다시 시도해 주세요.');
return;
}
// 중복 파일명 제거 (같은 아티팩트가 중복 매핑된 경우 방어)
const uniqueFiles = [];
const seen = new Set();
for (const f of files) {
if (!seen.has(f.path)) {
seen.add(f.path);
uniqueFiles.push(f);
}
}
const list = uniqueFiles.map(f => ` • ${f.name}`).join('\n');
if (!confirm(
`${uniqueFiles.length}개 아티팩트를 프로젝트에 추가합니다:\n\n${list}\n\n계속하시겠습니까?`
)) return;
btn.disabled = true;
btn.style.opacity = '0.6';
btn.style.cursor = 'wait';
const origHTML = btn.innerHTML;
let ok = 0, fail = 0;
for (let i = 0; i < uniqueFiles.length; i++) {
const f = uniqueFiles[i];
btn.innerHTML = `${FOLDER_PLUS_SVG} ${i + 1}/${uniqueFiles.length} ${f.name}`;
try {
const content = await downloadFile(orgId, convId, f.path);
await addToProject(orgId, projectId, f.name, content);
ok++;
} catch (e) {
console.error('[BatchAdd]', f.name, e);
fail++;
}
await new Promise(r => setTimeout(r, 300));
}
btn.disabled = false;
btn.style.opacity = '1';
btn.style.cursor = 'pointer';
btn.innerHTML = `${FOLDER_PLUS_SVG} ✅ ${ok} added` + (fail ? ` ❌ ${fail} failed` : '');
btn.style.color = fail ? '#dc2626' : '#16a34a';
btn.style.borderColor = fail ? '#fca5a5' : '#86efac';
setTimeout(() => {
btn.innerHTML = origHTML;
btn.style.color = 'rgb(180, 83, 9)';
btn.style.borderColor = 'rgba(180, 83, 9, 0.4)';
btn.style.background = 'transparent';
}, 5000);
});
// 삽입
const { parent, after } = findInsertionPoint(container);
if (after) {
parent.insertBefore(btn, after.nextSibling);
} else {
parent.appendChild(btn);
}
}
// ── Debounce with maxWait ─────────────────────────
//
// 일반 debounce는 mutation이 계속 오면 영원히 지연됨.
// maxWait를 두어 최소 N ms마다 1회는 scan이 실행되도록 보장.
function debounceWithMaxWait(fn, wait, maxWait) {
let timer = null;
let lastInvoke = 0;
return function (...args) {
const now = Date.now();
clearTimeout(timer);
const sinceLastInvoke = now - lastInvoke;
if (sinceLastInvoke >= maxWait) {
// maxWait 초과: 즉시 실행
lastInvoke = now;
fn(...args);
} else {
// 아직 maxWait 내: debounce 유지하되 maxWait 한도 내에서
const remaining = maxWait - sinceLastInvoke;
timer = setTimeout(() => {
lastInvoke = Date.now();
fn(...args);
}, Math.min(wait, remaining));
}
};
}
// ── 스캔 로직 ─────────────────────────────────────
function scan() {
findArtifactGroups().forEach((arts, container) => {
updateOrCreateButton(arts, container);
});
}
// ── 스트리밍 종료 감지 ────────────────────────────
//
// 스트리밍이 끝나면 최종 scan을 수행하여
// fiber 정보가 완전히 갖춰진 상태에서 버튼을 갱신.
let wasStreaming = false;
function checkStreamingEnd() {
const streaming = isStreaming();
if (wasStreaming && !streaming) {
// 스트리밍이 방금 끝남 → 약간의 딜레이 후 최종 scan
setTimeout(scan, 800);
setTimeout(scan, 2000); // 안전을 위해 한 번 더
}
wasStreaming = streaming;
}
// ── Observer 설정 ─────────────────────────────────
// 초기 스캔
setTimeout(scan, 1500);
// debounce: 500ms 대기, 최대 2000ms마다 1회 보장
const debouncedScan = debounceWithMaxWait(scan, 500, 2000);
const obs = new MutationObserver((mutations) => {
debouncedScan();
checkStreamingEnd();
});
obs.observe(document.body, { childList: true, subtree: true });
// 스트리밍 종료를 폴링으로도 감지 (MutationObserver가 놓칠 수 있으므로)
setInterval(checkStreamingEnd, 1500);
// ── URL 변경 감지 (SPA 네비게이션) ────────────────
//
// claude.ai는 SPA이므로 채팅 전환 시 URL이 바뀌지만
// 페이지가 새로고침되지 않음. 이 경우 재스캔 필요.
let lastUrl = location.href;
const urlObserver = new MutationObserver(() => {
if (location.href !== lastUrl) {
lastUrl = location.href;
// 새 채팅으로 전환: 기존 타이머 정리 후 재스캔
setTimeout(scan, 1500);
setTimeout(scan, 3000);
}
});
urlObserver.observe(document.querySelector('head > title') || document.head, {
childList: true,
subtree: true,
characterData: true,
});
// popstate (뒤로가기/앞으로가기)
window.addEventListener('popstate', () => {
setTimeout(scan, 1500);
});
console.log('[Claude Batch Add] v1.2 loaded — real-time tracking enabled');
})();
// ==UserScript==
// @name Claude Project Knowledge Tools
// @namespace https://claude.ai/
// @version 1.7
// @description Backup project files (+ instructions as CLAUDE.md), multi-select files by name, highlight duplicates with KEEP/OLD distinction
// @match https://claude.ai/project/*
// @grant GM_registerMenuCommand
// @grant GM_setClipboard
// @grant GM_getValue
// @grant GM_setValue
// @run-at document-idle
// ==/UserScript==
(function () {
'use strict';
// ═══════════════════════════════════════════════════════════
// ── Load JSZip in the PAGE context (avoids sandbox hang) ──
// ═══════════════════════════════════════════════════════════
function loadPageJSZip() {
return new Promise((resolve, reject) => {
if (unsafeWindow.JSZip) return resolve(unsafeWindow.JSZip);
const s = document.createElement('script');
s.src = 'https://cdnjs.cloudflare.com/ajax/libs/jszip/3.10.1/jszip.min.js';
s.onload = () => {
if (unsafeWindow.JSZip) resolve(unsafeWindow.JSZip);
else reject(new Error('JSZip loaded but not found on window'));
};
s.onerror = () => reject(new Error('Failed to load JSZip from CDN'));
document.head.appendChild(s);
});
}
// ═══════════════════════════════════════════════════════════
// ── Toast notification system ──
// ═══════════════════════════════════════════════════════════
const TOAST_CONTAINER_ID = '__ct-toast-container';
const TOAST_STYLE_ID = '__ct-toast-styles';
function injectToastStyles() {
if (document.getElementById(TOAST_STYLE_ID)) return;
const style = document.createElement('style');
style.id = TOAST_STYLE_ID;
style.textContent = `
#${TOAST_CONTAINER_ID} {
position: fixed;
bottom: 24px;
left: 50%;
transform: translateX(-50%);
z-index: 100000;
display: flex;
flex-direction: column-reverse;
align-items: center;
gap: 10px;
pointer-events: none;
}
.ct-toast {
pointer-events: auto;
display: flex;
align-items: center;
gap: 10px;
padding: 12px 20px;
border-radius: 10px;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
font-size: 14px;
font-weight: 500;
color: #fff;
box-shadow: 0 6px 24px rgba(0,0,0,.2);
opacity: 0;
transform: translateY(16px) scale(0.97);
animation: ctToastIn 0.35s ease forwards;
max-width: 480px;
}
.ct-toast.ct-toast-out {
animation: ctToastOut 0.3s ease forwards;
}
.ct-toast-success { background: #1a7f37; }
.ct-toast-info { background: #0969da; }
.ct-toast-warning { background: #bf8700; }
.ct-toast-error { background: #cf222e; }
.ct-toast-progress { background: #333; }
.ct-toast-icon { font-size: 18px; flex-shrink: 0; line-height: 1; }
.ct-toast-body { flex: 1; line-height: 1.4; }
.ct-toast-body small {
display: block; font-weight: 400; font-size: 12px; opacity: 0.85; margin-top: 2px;
}
.ct-toast-dismiss {
background: none; border: none; color: rgba(255,255,255,.7);
font-size: 16px; cursor: pointer; padding: 0 2px; flex-shrink: 0; line-height: 1;
}
.ct-toast-dismiss:hover { color: #fff; }
.ct-toast-spinner {
width: 18px; height: 18px;
border: 2px solid rgba(255,255,255,.3);
border-top-color: #fff;
border-radius: 50%;
animation: ctSpin 0.7s linear infinite;
flex-shrink: 0;
}
@keyframes ctToastIn { to { opacity: 1; transform: translateY(0) scale(1); } }
@keyframes ctToastOut { from { opacity: 1; transform: translateY(0) scale(1); } to { opacity: 0; transform: translateY(12px) scale(0.95); } }
@keyframes ctSpin { to { transform: rotate(360deg); } }
`;
document.head.appendChild(style);
}
function getToastContainer() {
let c = document.getElementById(TOAST_CONTAINER_ID);
if (!c) { c = document.createElement('div'); c.id = TOAST_CONTAINER_ID; document.body.appendChild(c); }
return c;
}
function showToast({ type = 'info', icon = 'ℹ️', message, detail, duration = 4000 }) {
injectToastStyles();
const container = getToastContainer();
const toast = document.createElement('div');
toast.className = `ct-toast ct-toast-${type}`;
const iconEl = document.createElement('span'); iconEl.className = 'ct-toast-icon';
const bodyEl = document.createElement('div'); bodyEl.className = 'ct-toast-body';
const dismissBtn = document.createElement('button');
dismissBtn.className = 'ct-toast-dismiss'; dismissBtn.textContent = '✕'; dismissBtn.title = 'Dismiss';
function render({ type: t, icon: i, message: m, detail: d } = {}) {
if (t) toast.className = `ct-toast ct-toast-${t}`;
if (t === 'progress') {
iconEl.innerHTML = ''; const s = document.createElement('div'); s.className = 'ct-toast-spinner'; iconEl.appendChild(s);
} else { iconEl.textContent = i ?? icon; }
bodyEl.innerHTML = '';
bodyEl.appendChild(document.createTextNode(m ?? message));
if (d) { const sm = document.createElement('small'); sm.textContent = d; bodyEl.appendChild(sm); }
}
render({ type, icon, message, detail });
toast.append(iconEl, bodyEl, dismissBtn);
container.appendChild(toast);
let timer = null;
function dismiss() {
if (timer) clearTimeout(timer);
toast.classList.add('ct-toast-out');
toast.addEventListener('animationend', () => toast.remove(), { once: true });
}
dismissBtn.addEventListener('click', dismiss);
if (duration > 0) timer = setTimeout(dismiss, duration);
return { el: toast, dismiss, update(opts) {
if (timer) { clearTimeout(timer); timer = null; }
render(opts);
if (opts.duration && opts.duration > 0) timer = setTimeout(dismiss, opts.duration);
}};
}
// ── Shared: React fiber walker ──
function getDocFromButton(btn) {
const fk = Object.keys(btn).find(k => k.startsWith('__reactFiber'));
if (!fk) return null;
let node = btn[fk];
for (let d = 0; node && d < 15; d++, node = node.return) {
if (node.memoizedProps?.doc?.file_name) return node.memoizedProps.doc;
}
return null;
}
function getAllDocs() {
const seen = new Set(); const docs = [];
for (const btn of document.querySelectorAll('button')) {
const doc = getDocFromButton(btn);
if (doc?.uuid && !seen.has(doc.uuid)) { seen.add(doc.uuid); docs.push(doc); }
}
return docs;
}
// ── Extract project instructions for CLAUDE.md ──
function getInstructions() {
const heading = Array.from(document.querySelectorAll('h1, h2, h3, h4, [class*="heading"]'))
.find(el => el.textContent.trim() === 'Instructions');
if (!heading) return null;
let el = heading;
for (let climb = 0; el && climb < 8; climb++, el = el.parentElement) {
const fk = Object.keys(el).find(k => k.startsWith('__reactFiber'));
if (!fk) continue;
let node = el[fk];
for (let d = 0; node && d < 25; d++, node = node.return) {
const p = node.memoizedProps;
if (p?.project?.prompt_template) return p.project.prompt_template;
if (p?.instructions && typeof p.instructions === 'string') return p.instructions;
}
}
const section = heading.closest('[class*="flex-col"]');
const textEl = section?.querySelector('[class*="prose"], [class*="instruction"], [class*="text"]')
|| section?.querySelector('div > div');
return textEl?.innerText || null;
}
// ═══════════════════════════════════════════════════════════
// ── 1. Backup all project knowledge ──
// ═══════════════════════════════════════════════════════════
async function backupProject() {
const docs = getAllDocs();
if (!docs.length) {
showToast({ type: 'warning', icon: '📭', message: 'No project docs found.', duration: 3000 });
return;
}
const LOG_ID = '__ct-backup-log';
document.getElementById(LOG_ID)?.remove();
const logPanel = document.createElement('div');
logPanel.id = LOG_ID;
Object.assign(logPanel.style, {
position: 'fixed', bottom: '80px', right: '20px', zIndex: '100001',
width: '380px', maxHeight: '50vh', overflowY: 'auto',
background: '#1e1e1e', color: '#d4d4d4', fontFamily: 'monospace',
fontSize: '11px', lineHeight: '1.5', padding: '12px 14px',
borderRadius: '10px', boxShadow: '0 8px 32px rgba(0,0,0,.4)',
});
document.body.appendChild(logPanel);
const startTime = performance.now();
function log(msg, color = '#d4d4d4') {
const elapsed = ((performance.now() - startTime) / 1000).toFixed(1);
const line = document.createElement('div');
line.style.color = color;
line.textContent = `[${elapsed}s] ${msg}`;
logPanel.appendChild(line);
logPanel.scrollTop = logPanel.scrollHeight;
}
function logError(msg) { log(`❌ ${msg}`, '#f44'); }
function logOk(msg) { log(`✅ ${msg}`, '#4ec944'); }
function logInfo(msg) { log(`ℹ️ ${msg}`, '#6cb6ff'); }
function logWarn(msg) { log(`⚠️ ${msg}`, '#e5a633'); }
const closeBtn = document.createElement('button');
Object.assign(closeBtn.style, {
position: 'absolute', top: '6px', right: '8px', background: 'none',
border: 'none', color: '#888', fontSize: '14px', cursor: 'pointer',
});
closeBtn.textContent = '✕';
closeBtn.onclick = () => logPanel.remove();
logPanel.appendChild(closeBtn);
log(`Backup started — ${docs.length} docs found`);
const tick = () => new Promise(r => setTimeout(r, 0));
try {
logInfo('Loading JSZip…');
await tick();
const PageJSZip = await loadPageJSZip();
logOk('JSZip ready');
logInfo('Creating ZIP…');
await tick();
const zip = new PageJSZip();
const count = {};
for (let i = 0; i < docs.length; i++) {
const doc = docs[i];
const name = doc.file_name;
count[name] = (count[name] || 0) + 1;
const ext = name.lastIndexOf('.');
const zipName = count[name] > 1
? (ext > 0 ? `${name.slice(0, ext)}_${count[name]}${name.slice(ext)}` : `${name}_${count[name]}`)
: name;
zip.file(zipName, doc.content ?? '');
log(` + ${zipName} (${(doc.content ?? '').length} chars)`);
if (i % 10 === 9) await tick();
}
logInfo('Looking for instructions…');
await tick();
const instructions = getInstructions();
if (instructions) {
zip.file('CLAUDE.md', instructions);
logOk(`CLAUDE.md added (${instructions.length} chars)`);
} else {
logWarn('No instructions — skipping CLAUDE.md');
}
logInfo('Generating ZIP…');
await tick();
const blob = await zip.generateAsync(
{ type: 'blob', compression: 'STORE' },
meta => { if (meta.percent && meta.percent % 25 < 1) log(` ${meta.percent.toFixed(0)}%`); }
);
logOk(`Blob ready — ${(blob.size / 1024).toFixed(1)} KB`);
const title = document.title.replace(/[^\w\s-]/g, '').trim();
const date = new Date().toISOString().slice(0, 10);
const fileName = `${title}-backup-${date}.zip`;
const a = Object.assign(document.createElement('a'), {
href: URL.createObjectURL(blob), download: fileName,
});
document.body.appendChild(a); a.click(); a.remove();
URL.revokeObjectURL(a.href);
logOk(`Download: ${fileName}`);
const extra = instructions ? ' + CLAUDE.md' : '';
showToast({
type: 'success', icon: '✅', message: 'Backup complete!',
detail: `${docs.length} files${extra} · ${(blob.size / (1024 * 1024)).toFixed(1)} MB`,
duration: 6000,
});
setTimeout(() => {
if (logPanel.parentElement) {
logPanel.style.transition = 'opacity 0.5s';
logPanel.style.opacity = '0';
setTimeout(() => logPanel.remove(), 500);
}
}, 8000);
} catch (err) {
logError(`FAILED: ${err.message}`);
showToast({ type: 'error', icon: '❌', message: 'Backup failed', detail: err.message, duration: 8000 });
}
}
// ── Robust filename parser ──
function parseFileNames(raw) {
return raw.split(/[,\n]+/).map(s => s.trim()).map(s => s.replace(/^["']+|["']+$/g, ''))
.map(s => s.replace(/^\d+\.\s*/, '')).map(s => s.replace(/^[-•*]\s*/, '')).filter(Boolean);
}
// ═══════════════════════════════════════════════════════════
// ── 2. Multi-select files by name ──
// ═══════════════════════════════════════════════════════════
function selectFilesByName(names) {
const targetSet = new Set(names);
const results = { checked: [], alreadyChecked: [], notFound: [] };
const found = new Set();
for (const btn of document.querySelectorAll('button')) {
const doc = getDocFromButton(btn);
if (!doc?.file_name || !targetSet.has(doc.file_name)) continue;
found.add(doc.file_name);
const cb = Array.from(btn.querySelectorAll('input[type="checkbox"]'))
.find(c => !c.closest('.delete-checkbox-overlay'));
if (!cb) continue;
if (cb.checked) { results.alreadyChecked.push(doc.file_name); }
else { cb.scrollIntoView({ block: 'center' }); cb.click(); results.checked.push(doc.file_name); }
}
for (const n of names) { if (!found.has(n)) results.notFound.push(n); }
return results;
}
function promptAndSelect() {
const input = prompt('Enter file names to select\n(comma-separated, one per line, or mixed):\n\n' +
'Example:\nsession-33-handoff.md, session-34-handoff.md\ndoc-c-draft-v0_4.md');
if (!input) return;
const names = parseFileNames(input);
if (!names.length) return alert('No valid file names found.');
const r = selectFilesByName(names);
const lines = [];
if (r.checked.length) lines.push(`✅ ${r.checked.length} newly selected`);
if (r.alreadyChecked.length) lines.push(`⏭ ${r.alreadyChecked.length} already selected`);
if (r.notFound.length) lines.push(`❌ ${r.notFound.length} not found:\n ${r.notFound.join('\n ')}`);
alert(lines.join('\n'));
return r;
}
// ═══════════════════════════════════════════════════════════
// ── 3. List all files → clipboard ──
// ═══════════════════════════════════════════════════════════
function listFiles() {
const docs = getAllDocs();
GM_setClipboard(docs.map(d => d.file_name).join('\n'));
showToast({ type: 'success', icon: '📋', message: `${docs.length} file names copied`, duration: 3000 });
}
// ═══════════════════════════════════════════════════════════
// ── 4. Highlight Duplicates — KEEP / OLD ──
// ═══════════════════════════════════════════════════════════
//
// Design principle:
// DOM 순서 첫 번째 = 페이지 최상단 = 가장 최근 추가 → KEEP (녹색)
// 나머지 = 이전 버전 → OLD (적색, 삭제 후보)
//
// 이렇게 하면 "어떤 걸 지워야 하지?" 고민 없이
// 빨간 OLD만 삭제하면 됩니다.
const DUP_STYLE_ID = '__ct-dup-styles';
const DUP_PANEL_ID = '__ct-dup-panel';
const DUP_ATTR = 'data-ct-duplicate';
const DUP_ROLE_ATTR = 'data-ct-dup-role'; // "keep" | "old"
let dupEnabled = GM_getValue('dupHighlightEnabled', true);
let panelExpanded = false;
// ── Fingerprint ──
let lastFileFingerprint = '';
let hasEverSeenFiles = false;
function getFileFingerprint() {
const ids = [];
for (const btn of document.querySelectorAll('button')) {
const doc = getDocFromButton(btn);
if (doc?.file_name) ids.push(doc.uuid || doc.file_name);
}
return ids.sort().join('|');
}
// ── Styles ──
function injectDupStyles() {
if (document.getElementById(DUP_STYLE_ID)) return;
const style = document.createElement('style');
style.id = DUP_STYLE_ID;
style.textContent = `
/* ─── OLD: deletion candidates ─── */
button[${DUP_ATTR}][${DUP_ROLE_ATTR}="old"] {
outline: 2px solid #dc2626 !important;
outline-offset: -2px;
background: #fef2f2 !important;
position: relative;
}
button[${DUP_ATTR}][${DUP_ROLE_ATTR}="old"]::after {
content: 'OLD';
position: absolute; top: 4px; right: 4px;
font-size: 9px; font-weight: 700; color: #fff; background: #dc2626;
padding: 1px 5px; border-radius: 3px; line-height: 1.4;
pointer-events: none; z-index: 2;
}
/* ─── KEEP: newest copy ─── */
button[${DUP_ATTR}][${DUP_ROLE_ATTR}="keep"] {
outline: 2px solid #16a34a !important;
outline-offset: -2px;
position: relative;
}
button[${DUP_ATTR}][${DUP_ROLE_ATTR}="keep"]::after {
content: 'KEEP';
position: absolute; top: 4px; right: 4px;
font-size: 9px; font-weight: 700; color: #fff; background: #16a34a;
padding: 1px 5px; border-radius: 3px; line-height: 1.4;
pointer-events: none; z-index: 2;
}
/* ─── Panel ─── */
#${DUP_PANEL_ID} {
margin: 8px 0 4px;
border: 1px solid #e5e7eb;
border-radius: 10px;
background: #fafafa;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
overflow: hidden;
transition: border-color 0.2s;
}
#${DUP_PANEL_ID}:hover { border-color: #d1d5db; }
#${DUP_PANEL_ID} .ct-dp-bar {
display: flex; align-items: center; gap: 8px;
padding: 8px 12px; cursor: pointer; user-select: none;
}
#${DUP_PANEL_ID} .ct-dp-bar:hover { background: #f3f4f6; }
#${DUP_PANEL_ID} .ct-dp-chevron {
font-size: 11px; color: #9ca3af; transition: transform 0.2s ease;
flex-shrink: 0; width: 16px; text-align: center;
}
#${DUP_PANEL_ID}.ct-dp-expanded .ct-dp-chevron { transform: rotate(90deg); }
#${DUP_PANEL_ID} .ct-dp-icon { font-size: 14px; flex-shrink: 0; }
#${DUP_PANEL_ID} .ct-dp-label { font-size: 12px; font-weight: 600; color: #6b7280; flex: 1; }
#${DUP_PANEL_ID} .ct-dp-count {
font-size: 11px; font-weight: 600; color: #fff; background: #dc2626;
padding: 1px 7px; border-radius: 9px; line-height: 1.5; flex-shrink: 0;
}
#${DUP_PANEL_ID} .ct-dp-body {
max-height: 0; overflow: hidden; transition: max-height 0.3s ease;
}
#${DUP_PANEL_ID}.ct-dp-expanded .ct-dp-body {
max-height: 600px; overflow-y: auto;
}
#${DUP_PANEL_ID} .ct-dp-inner {
padding: 0 12px 10px; border-top: 1px solid #e5e7eb;
}
#${DUP_PANEL_ID} .ct-dp-group { margin-top: 10px; }
#${DUP_PANEL_ID} .ct-dp-group-name {
font-weight: 700; font-size: 12px; color: #374151; margin-bottom: 3px;
}
#${DUP_PANEL_ID} .ct-dp-entry {
display: flex; align-items: center; gap: 6px;
font-size: 11px; color: #777; padding: 3px 0 3px 10px;
cursor: pointer; border-radius: 4px;
}
#${DUP_PANEL_ID} .ct-dp-entry:hover { background: #f3f4f6; color: #555; }
#${DUP_PANEL_ID} .ct-dp-role-badge {
font-size: 9px; font-weight: 700; color: #fff;
padding: 0 4px; border-radius: 3px; line-height: 1.6; flex-shrink: 0;
}
#${DUP_PANEL_ID} .ct-dp-role-keep { background: #16a34a; }
#${DUP_PANEL_ID} .ct-dp-role-old { background: #dc2626; }
#${DUP_PANEL_ID} .ct-dp-lines { white-space: nowrap; }
#${DUP_PANEL_ID} .ct-dp-uuid { font-family: monospace; font-size: 10px; color: #bbb; white-space: nowrap; }
#${DUP_PANEL_ID} .ct-dp-summary {
margin-top: 10px; padding-top: 8px;
border-top: 1px solid #e5e7eb; font-size: 11px; color: #999;
}
#${DUP_PANEL_ID} .ct-dp-hint {
margin-top: 4px; font-size: 10px; color: #9ca3af; font-style: italic;
}
#${DUP_PANEL_ID}.ct-dp-hidden { display: none; }
/* ─── Cleanup action bar ─── */
#${DUP_PANEL_ID} .ct-dp-cleanup {
margin-top: 10px; padding-top: 10px;
border-top: 1px solid #e5e7eb;
display: flex; align-items: center; gap: 10px; flex-wrap: wrap;
}
#${DUP_PANEL_ID} .ct-dp-cleanup-cb {
display: flex; align-items: center; gap: 5px;
font-size: 11px; color: #6b7280; cursor: pointer; user-select: none;
}
#${DUP_PANEL_ID} .ct-dp-cleanup-cb input {
accent-color: #16a34a; cursor: pointer;
}
#${DUP_PANEL_ID} .ct-dp-select-btn {
display: inline-flex; align-items: center; gap: 4px;
padding: 5px 10px; border: 1px solid #dc2626; border-radius: 6px;
background: #fff; color: #dc2626; font-size: 11px; font-weight: 600;
cursor: pointer; transition: all 0.15s; line-height: 1.4;
}
#${DUP_PANEL_ID} .ct-dp-select-btn:hover {
background: #fef2f2; border-color: #b91c1c;
}
#${DUP_PANEL_ID} .ct-dp-select-btn:disabled {
opacity: 0.5; cursor: wait;
}
`;
document.head.appendChild(style);
}
function removeDupStyles() { document.getElementById(DUP_STYLE_ID)?.remove(); }
// ── Duplicate detection ──
// DOM-order first = topmost = newest → role:"keep"
// Rest → role:"old"
function findDuplicates() {
const byName = {};
for (const btn of document.querySelectorAll('button')) {
const doc = getDocFromButton(btn);
if (!doc?.file_name) continue;
if (!byName[doc.file_name]) byName[doc.file_name] = [];
byName[doc.file_name].push({ doc, btn });
}
const dupes = {};
for (const [name, entries] of Object.entries(byName)) {
if (entries.length <= 1) continue;
dupes[name] = entries.map((entry, idx) => ({
...entry,
role: idx === 0 ? 'keep' : 'old',
}));
}
return dupes;
}
// ── Select OLD files for removal (with optional backup) ──
let backupBeforeCleanup = true; // persisted in checkbox state only
async function selectOldForRemoval() {
const dupes = findDuplicates();
const oldEntries = [];
for (const [name, entries] of Object.entries(dupes)) {
for (const entry of entries) {
if (entry.role === 'old') oldEntries.push({ ...entry, fileName: name });
}
}
if (!oldEntries.length) {
showToast({ type: 'info', icon: 'ℹ️', message: 'No OLD duplicates to remove.', duration: 3000 });
return;
}
// ── confirm() with file list ──
const list = oldEntries.map(e => ` ✕ ${e.fileName}`).join('\n');
const backupNote = backupBeforeCleanup
? '\n⚠️ A backup will be created first.\n'
: '\n⚠️ No backup will be made!\n';
const ok = confirm(
`Select ${oldEntries.length} OLD file${oldEntries.length !== 1 ? 's' : ''} for removal:\n\n` +
list + '\n' +
backupNote +
`The newest copy (KEEP) of each will be preserved.\nProceed?`
);
if (!ok) return;
// ── Optional backup ──
if (backupBeforeCleanup) {
const toast = showToast({
type: 'progress', message: 'Backing up project before cleanup…', duration: 0,
});
try {
await backupProject();
toast.update({
type: 'success', icon: '✅',
message: 'Backup complete — selecting OLD files…',
duration: 2000,
});
} catch (err) {
toast.update({
type: 'error', icon: '❌',
message: 'Backup failed — aborting cleanup',
detail: err?.message, duration: 6000,
});
return; // abort if backup fails
}
// Small delay so user sees the backup toast
await new Promise(r => setTimeout(r, 800));
}
// ── Click checkboxes on OLD buttons ──
let checked = 0, failed = 0;
for (const { btn, fileName } of oldEntries) {
const cb = Array.from(btn.querySelectorAll('input[type="checkbox"]'))
.find(c => !c.closest('.delete-checkbox-overlay'));
if (!cb) { failed++; continue; }
if (cb.checked) continue; // already checked
cb.scrollIntoView({ block: 'center' });
cb.click();
checked++;
await new Promise(r => setTimeout(r, 150));
}
const detail = backupBeforeCleanup ? 'Backup saved. Review checked files, then delete.' : 'Review checked files carefully, then delete.';
showToast({
type: checked > 0 ? 'warning' : 'info',
icon: checked > 0 ? '☑️' : 'ℹ️',
message: checked > 0
? `${checked} OLD file${checked !== 1 ? 's' : ''} checked for removal`
: 'No new files to check',
detail: failed > 0 ? `${failed} could not be selected. ${detail}` : detail,
duration: 6000,
});
}
// ── Apply highlights ──
function applyHighlights() {
document.querySelectorAll(`button[${DUP_ATTR}]`).forEach(b => {
b.removeAttribute(DUP_ATTR);
b.removeAttribute(DUP_ROLE_ATTR);
});
if (!dupEnabled) return {};
const dupes = findDuplicates();
for (const [, entries] of Object.entries(dupes)) {
for (const { btn, role } of entries) {
btn.setAttribute(DUP_ATTR, 'true');
btn.setAttribute(DUP_ROLE_ATTR, role);
}
}
return dupes;
}
// ── Files section container ──
function getFilesSectionContainer() {
const h = Array.from(document.querySelectorAll('h3')).find(el => el.textContent.trim() === 'Files');
return h?.parentElement?.parentElement || null;
}
// ── Panel rendering ──
let isRendering = false;
function renderDupPanel() {
if (isRendering) return;
isRendering = true;
try {
const dupes = applyHighlights();
const names = Object.keys(dupes).sort();
const oldFiles = names.reduce((sum, n) => sum + dupes[n].filter(e => e.role === 'old').length, 0);
const totalFiles = names.reduce((sum, n) => sum + dupes[n].length, 0);
let panel = document.getElementById(DUP_PANEL_ID);
if (!panel) {
panel = document.createElement('div');
panel.id = DUP_PANEL_ID;
const bar = document.createElement('div');
bar.className = 'ct-dp-bar';
const chevron = document.createElement('span'); chevron.className = 'ct-dp-chevron'; chevron.textContent = '▶';
const icon = document.createElement('span'); icon.className = 'ct-dp-icon'; icon.textContent = '📋';
const label = document.createElement('span'); label.className = 'ct-dp-label';
const count = document.createElement('span'); count.className = 'ct-dp-count';
bar.append(chevron, icon, label, count);
bar.addEventListener('click', () => {
panelExpanded = !panelExpanded;
panel.classList.toggle('ct-dp-expanded', panelExpanded);
});
const body = document.createElement('div'); body.className = 'ct-dp-body';
const inner = document.createElement('div'); inner.className = 'ct-dp-inner';
body.appendChild(inner);
panel.append(bar, body);
const section = getFilesSectionContainer();
if (section) { section.appendChild(panel); }
else { document.querySelector('main')?.appendChild(panel); }
}
const label = panel.querySelector('.ct-dp-label');
const countBadge = panel.querySelector('.ct-dp-count');
const inner = panel.querySelector('.ct-dp-inner');
if (names.length === 0) {
panel.classList.add('ct-dp-hidden');
return;
}
panel.classList.remove('ct-dp-hidden');
label.textContent = 'Duplicates found';
countBadge.textContent = `${oldFiles} removable`;
panel.classList.toggle('ct-dp-expanded', panelExpanded);
inner.innerHTML = '';
for (const name of names) {
const entries = dupes[name];
const group = document.createElement('div'); group.className = 'ct-dp-group';
const gName = document.createElement('div'); gName.className = 'ct-dp-group-name';
gName.textContent = `${name} (×${entries.length})`;
group.appendChild(gName);
for (const { doc, btn, role } of entries) {
const entry = document.createElement('div'); entry.className = 'ct-dp-entry';
const badge = document.createElement('span');
badge.className = `ct-dp-role-badge ct-dp-role-${role}`;
badge.textContent = role === 'keep' ? 'KEEP' : 'OLD';
const linesSpan = document.createElement('span'); linesSpan.className = 'ct-dp-lines';
linesSpan.textContent = `${doc.content ? doc.content.split('\n').length : '?'} lines`;
const uuidSpan = document.createElement('span'); uuidSpan.className = 'ct-dp-uuid';
uuidSpan.textContent = doc.uuid ? doc.uuid.slice(0, 8) + '…' : '';
entry.append(badge, linesSpan, uuidSpan);
entry.addEventListener('click', () => {
btn.scrollIntoView({ behavior: 'smooth', block: 'center' });
const color = role === 'keep' ? '#16a34a' : '#dc2626';
btn.style.transition = 'box-shadow 0.2s';
btn.style.boxShadow = `0 0 0 4px ${color}`;
setTimeout(() => { btn.style.boxShadow = ''; }, 1500);
});
group.appendChild(entry);
}
inner.appendChild(group);
}
const summary = document.createElement('div'); summary.className = 'ct-dp-summary';
summary.textContent =
`${totalFiles} files in ${names.length} group${names.length !== 1 ? 's' : ''}. ` +
`${oldFiles} old cop${oldFiles !== 1 ? 'ies' : 'y'} safe to remove.`;
inner.appendChild(summary);
const hint = document.createElement('div'); hint.className = 'ct-dp-hint';
hint.textContent = 'KEEP = topmost (newest). OLD = older copies, safe to delete.';
inner.appendChild(hint);
// ── Cleanup action bar ──
const cleanup = document.createElement('div'); cleanup.className = 'ct-dp-cleanup';
// Backup checkbox
const cbLabel = document.createElement('label'); cbLabel.className = 'ct-dp-cleanup-cb';
const cbInput = document.createElement('input');
cbInput.type = 'checkbox';
cbInput.checked = backupBeforeCleanup;
cbInput.addEventListener('change', () => { backupBeforeCleanup = cbInput.checked; });
cbLabel.append(cbInput, document.createTextNode(' Backup first'));
cleanup.appendChild(cbLabel);
// Select OLD button
const selectBtn = document.createElement('button'); selectBtn.className = 'ct-dp-select-btn';
selectBtn.textContent = `☑ Select ${oldFiles} OLD`;
selectBtn.addEventListener('click', async (e) => {
e.stopPropagation(); // don't toggle panel
selectBtn.disabled = true;
selectBtn.textContent = '⏳ Working…';
try {
await selectOldForRemoval();
} finally {
selectBtn.disabled = false;
selectBtn.textContent = `☑ Select ${oldFiles} OLD`;
}
});
cleanup.appendChild(selectBtn);
inner.appendChild(cleanup);
} finally {
isRendering = false;
}
}
function removeDupPanel() { document.getElementById(DUP_PANEL_ID)?.remove(); }
function clearHighlights() {
document.querySelectorAll(`button[${DUP_ATTR}]`).forEach(b => {
b.removeAttribute(DUP_ATTR);
b.removeAttribute(DUP_ROLE_ATTR);
});
}
// ═══════════════════════════════════════════════════════════
// ── Observer — race-condition hardened ──
// ═══════════════════════════════════════════════════════════
let dupObserver = null;
let dupPollInterval = null;
let observedTarget = null;
let initRetryCount = 0;
const MAX_INIT_RETRIES = 20; // 20 × 500ms = 10s
let initRetryTimer = null;
function getSidebarCard() {
const section = getFilesSectionContainer();
return section?.parentElement || null;
}
function onFileListChange() {
if (!dupEnabled || isRendering) return;
const fp = getFileFingerprint();
// Empty fingerprint = files not loaded yet — don't cache
if (!fp) return;
if (!hasEverSeenFiles) hasEverSeenFiles = true;
if (fp === lastFileFingerprint) return;
lastFileFingerprint = fp;
renderDupPanel();
}
function attachObserver() {
if (dupObserver) { dupObserver.disconnect(); dupObserver = null; }
const card = getSidebarCard();
if (!card) return false;
observedTarget = card;
let debounceTimer = null;
dupObserver = new MutationObserver((mutations) => {
const onlyOurs = mutations.every(m =>
m.target.id === DUP_PANEL_ID ||
m.target.closest?.(`#${DUP_PANEL_ID}`)
);
if (onlyOurs) return;
clearTimeout(debounceTimer);
debounceTimer = setTimeout(onFileListChange, 200);
});
dupObserver.observe(card, { childList: true, subtree: true });
return true;
}
function startDupObserver() {
attachObserver();
if (dupPollInterval) clearInterval(dupPollInterval);
dupPollInterval = setInterval(() => {
if (!dupEnabled) return;
const card = getSidebarCard();
if (card && card !== observedTarget) {
attachObserver();
onFileListChange();
return;
}
if (!document.getElementById(DUP_PANEL_ID)) {
lastFileFingerprint = '';
onFileListChange();
}
}, 2000);
}
function stopDupObserver() {
if (dupObserver) { dupObserver.disconnect(); dupObserver = null; observedTarget = null; }
if (dupPollInterval) { clearInterval(dupPollInterval); dupPollInterval = null; }
if (initRetryTimer) { clearTimeout(initRetryTimer); initRetryTimer = null; }
}
// ── Init with retry ──
function initDupHighlight() {
if (!dupEnabled) return;
injectDupStyles();
const fp = getFileFingerprint();
if (!fp && initRetryCount < MAX_INIT_RETRIES) {
// Files not loaded yet → retry in 500ms
initRetryCount++;
initRetryTimer = setTimeout(initDupHighlight, 500);
return;
}
if (fp) {
hasEverSeenFiles = true;
lastFileFingerprint = ''; // force first render
}
renderDupPanel();
startDupObserver();
}
// ── Toggle ──
function toggleDupHighlight() {
dupEnabled = !dupEnabled;
GM_setValue('dupHighlightEnabled', dupEnabled);
if (dupEnabled) {
initRetryCount = 0;
hasEverSeenFiles = false;
lastFileFingerprint = '';
initDupHighlight();
} else {
clearHighlights();
removeDupPanel();
removeDupStyles();
stopDupObserver();
lastFileFingerprint = '';
hasEverSeenFiles = false;
}
showToast({
type: dupEnabled ? 'info' : 'warning',
icon: dupEnabled ? '🔍' : '🔇',
message: `Duplicate highlight: ${dupEnabled ? 'ON' : 'OFF'}`,
duration: 2500
});
}
function showDuplicatesPanel() {
if (!dupEnabled) {
dupEnabled = true;
GM_setValue('dupHighlightEnabled', true);
injectDupStyles();
startDupObserver();
}
panelExpanded = true;
lastFileFingerprint = '';
renderDupPanel();
document.getElementById(DUP_PANEL_ID)?.scrollIntoView({ behavior: 'smooth', block: 'center' });
}
// ── Kick off ──
setTimeout(initDupHighlight, 800);
// ── Menu commands ──
GM_registerMenuCommand('📦 Backup Project Knowledge', backupProject);
GM_registerMenuCommand('☑️ Select Files by Name', promptAndSelect);
GM_registerMenuCommand('📋 List All Files → Clipboard', listFiles);
GM_registerMenuCommand(`🔍 Toggle Duplicate Highlight (${dupEnabled ? 'ON' : 'OFF'})`, toggleDupHighlight);
GM_registerMenuCommand('⚠️ Show Duplicates Panel', showDuplicatesPanel);
// ── Expose ──
unsafeWindow.__claudeTools = {
backupProject, selectFilesByName, parseFileNames, listFiles,
getAllDocs, getInstructions, findDuplicates, showDuplicatesPanel,
toggleDupHighlight, applyHighlights, renderDupPanel, showToast,
selectOldForRemoval
};
console.log('🔧 Claude Project Tools v1.7 loaded.');
})();
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment