Skip to content

Commit 50bb5e7

Browse files
ithiria894claude
andcommitted
feat: frontmatter config UI, scanner fixes, MCP panel scope-follow
- Skill/agent/memory frontmatter editor in detail panel (model, when_to_use, description, maxTurns) - Collapsible metadata section with universal data-tooltip component - Scanner reads both settings.json + settings.local.json for approval state - Scanner checks enableAllProjectMcpServers flag - MCP Controls panel follows scope switches (parity with Context Budget) - POST /api/save-frontmatter endpoint for writing frontmatter changes Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent a737088 commit 50bb5e7

5 files changed

Lines changed: 280 additions & 41 deletions

File tree

src/scanner.mjs

Lines changed: 18 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -624,19 +624,26 @@ async function scanMcpServers(scope) {
624624

625625
// Add approval state for project-scoped servers from .mcp.json
626626
// Mirrors ccsrc getProjectMcpServerStatus: approved/rejected/pending
627+
// CC merges settings from multiple sources; read both settings.json and
628+
// settings.local.json then merge (local wins for enableAllProjectMcpServers).
627629
if (scope.id !== "global") {
628630
try {
629-
const userSettingsRaw = await safeReadFile(join(CLAUDE_DIR, "settings.json"));
630-
if (userSettingsRaw) {
631-
const userSettings = JSON.parse(userSettingsRaw);
632-
const enabled = Array.isArray(userSettings.enabledMcpjsonServers) ? userSettings.enabledMcpjsonServers : [];
633-
const disabled = Array.isArray(userSettings.disabledMcpjsonServers) ? userSettings.disabledMcpjsonServers : [];
634-
for (const item of items) {
635-
if (item.fileName === ".mcp.json") {
636-
if (enabled.includes(item.name)) item.approvalState = "approved";
637-
else if (disabled.includes(item.name)) item.approvalState = "rejected";
638-
else item.approvalState = "pending";
639-
}
631+
const enabled = new Set();
632+
const disabled = new Set();
633+
let enableAll = false;
634+
for (const sf of ["settings.json", "settings.local.json"]) {
635+
const raw = await safeReadFile(join(CLAUDE_DIR, sf));
636+
if (!raw) continue;
637+
const s = JSON.parse(raw);
638+
if (Array.isArray(s.enabledMcpjsonServers)) s.enabledMcpjsonServers.forEach(n => enabled.add(n));
639+
if (Array.isArray(s.disabledMcpjsonServers)) s.disabledMcpjsonServers.forEach(n => disabled.add(n));
640+
if (s.enableAllProjectMcpServers) enableAll = true;
641+
}
642+
for (const item of items) {
643+
if (item.fileName === ".mcp.json") {
644+
if (disabled.has(item.name)) item.approvalState = "rejected";
645+
else if (enableAll || enabled.has(item.name)) item.approvalState = "approved";
646+
else item.approvalState = "pending";
640647
}
641648
}
642649
} catch {}

src/server.mjs

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -629,6 +629,26 @@ async function handleRequest(req, res) {
629629
}
630630
}
631631

632+
// POST /api/save-frontmatter — write updated markdown file content (skills, agents, memories)
633+
if (path === "/api/save-frontmatter" && req.method === "POST") {
634+
try {
635+
const body = await readBody(req);
636+
const { path: filePath, content } = body;
637+
if (!filePath || !isAbsolute(filePath) || !isPathAllowed(filePath)) {
638+
return json(res, { ok: false, error: "Invalid or disallowed path" }, 400);
639+
}
640+
if (!filePath.endsWith(".md")) {
641+
return json(res, { ok: false, error: "Only .md files can be updated" }, 400);
642+
}
643+
const { writeFile: wf } = await import("node:fs/promises");
644+
await wf(filePath, content, "utf-8");
645+
cachedData = null;
646+
return json(res, { ok: true });
647+
} catch (err) {
648+
return json(res, { ok: false, error: err.message }, 500);
649+
}
650+
}
651+
632652
// GET /api/session-preview?path=... — parse JSONL session into structured conversation
633653
if (path === "/api/session-preview" && req.method === "GET") {
634654
const filePath = url.searchParams.get("path");

src/ui/app.js

Lines changed: 154 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -216,6 +216,8 @@ function setupSidebarTree() {
216216
expandScopePath(selectedScopeId);
217217
if (isContextBudgetOpen()) {
218218
openContextBudget(selectedScopeId);
219+
} else if (!document.getElementById("mcpControlsPanel").classList.contains("hidden")) {
220+
openMcpControlsPanel();
219221
}
220222
const cat = catRow.dataset.cat;
221223
if (activeFilters.size === 1 && activeFilters.has(cat)) {
@@ -246,6 +248,8 @@ function setupSidebarTree() {
246248
expandScopePath(selectedScopeId);
247249
if (isContextBudgetOpen()) {
248250
openContextBudget(selectedScopeId);
251+
} else if (!document.getElementById("mcpControlsPanel").classList.contains("hidden")) {
252+
openMcpControlsPanel();
249253
} else if (selectedItem && selectedItem.scopeId !== selectedScopeId) {
250254
closeDetail();
251255
}
@@ -418,6 +422,17 @@ function setupDetailPanel() {
418422
document.getElementById("costBreakdown").classList.add("hidden");
419423
document.querySelector(".detail-body").classList.remove("hidden");
420424
});
425+
426+
// Metadata collapsible toggle
427+
const metaToggle = document.getElementById("detailMetaToggle");
428+
const metaBody = document.getElementById("detailMetaBody");
429+
if (metaToggle && metaBody) {
430+
metaToggle.addEventListener("click", () => {
431+
metaBody.classList.toggle("hidden");
432+
metaToggle.classList.toggle("open");
433+
metaToggle.querySelector(".d-meta-arrow").textContent = metaBody.classList.contains("hidden") ? "▸" : "▾";
434+
});
435+
}
421436
}
422437

423438
function formatTokenCount(n) {
@@ -1183,6 +1198,9 @@ function renderDetailPanel(resetPreview = false) {
11831198
// Why it applies (Effective Behavior section)
11841199
renderEffectiveBehavior(selectedItem);
11851200

1201+
// Frontmatter config (model, when_to_use, description, maxTurns etc.)
1202+
renderItemConfig(selectedItem);
1203+
11861204
// CC Actions — contextual prompt buttons
11871205
renderCcActions(selectedItem);
11881206

@@ -1281,6 +1299,142 @@ function renderEffectiveBehavior(item) {
12811299
text.textContent = why;
12821300
}
12831301

1302+
// ── Item Config (frontmatter fields for skills, agents, memories) ─────────────
1303+
1304+
const ITEM_CONFIG_FIELDS = {
1305+
skill: [
1306+
{ key: "model", label: "Model", type: "select", options: ["", "opus", "sonnet", "haiku", "inherit"], labels: ["(not set) — uses default model", "opus", "sonnet", "haiku", "inherit"], tooltip: "When set, Claude Code uses this model instead of your session default when running this skill. Official SKILL.md frontmatter field." },
1307+
{ key: "when_to_use", label: "When to use", type: "text", placeholder: "(not set) — describe when AI should auto-trigger this skill", tooltip: "Claude Code may auto-invoke skills based on context. Setting this gives more specific guidance on when this skill should be triggered." },
1308+
],
1309+
agent: [
1310+
{ key: "model", label: "Model", type: "select", options: ["", "opus", "sonnet", "haiku", "inherit"], labels: ["(not set) — uses default model", "opus", "sonnet", "haiku", "inherit"], tooltip: "When set, Claude Code uses this model instead of your session default when running this agent." },
1311+
{ key: "maxTurns", label: "Max turns", type: "number", placeholder: "(not set) — no limit", tooltip: "Maximum number of agentic turns before the agent stops. Prevents runaway agents from burning tokens indefinitely." },
1312+
],
1313+
memory: [
1314+
{ key: "description", label: "Description", type: "text", placeholder: "(not set) — one-line summary for Claude to decide relevance", tooltip: "Claude Code uses this to decide whether this memory is relevant to the current conversation. A clear description improves recall accuracy." },
1315+
],
1316+
};
1317+
1318+
let _itemConfigTimers = {};
1319+
1320+
function getItemFilePath(item) {
1321+
if (item.category === "skill") return `${item.path}/SKILL.md`;
1322+
if (item.category === "agent") return `${item.path}`;
1323+
if (item.category === "memory") return item.path;
1324+
return null;
1325+
}
1326+
1327+
async function renderItemConfig(item) {
1328+
const wrap = document.getElementById("detailItemConfig");
1329+
if (!wrap) return;
1330+
1331+
const fields = item ? ITEM_CONFIG_FIELDS[item.category] : null;
1332+
if (!fields) { wrap.classList.add("hidden"); wrap.innerHTML = ""; return; }
1333+
1334+
const filePath = getItemFilePath(item);
1335+
if (!filePath) { wrap.classList.add("hidden"); return; }
1336+
1337+
// Read file to get current frontmatter values
1338+
let fm = {};
1339+
try {
1340+
const res = await fetchJson(`/api/file-content?path=${encodeURIComponent(filePath)}`);
1341+
if (!res.ok) { wrap.classList.add("hidden"); return; }
1342+
fm = parseFrontmatter(res.content);
1343+
} catch { wrap.classList.add("hidden"); return; }
1344+
1345+
// Build HTML
1346+
let html = "";
1347+
for (const field of fields) {
1348+
html += `<div class="d-item-config-row">`;
1349+
html += `<span class="d-info-label" data-tooltip="${esc(field.tooltip)}">${esc(field.label)}</span>`;
1350+
1351+
if (field.type === "select") {
1352+
html += `<select class="d-item-select" data-fm-key="${esc(field.key)}" data-fm-path="${esc(filePath)}">`;
1353+
for (let i = 0; i < field.options.length; i++) {
1354+
const val = field.options[i];
1355+
const label = field.labels?.[i] || val;
1356+
const sel = (fm[field.key] || "") === val ? " selected" : "";
1357+
html += `<option value="${esc(val)}"${sel}>${esc(label)}</option>`;
1358+
}
1359+
html += `</select>`;
1360+
} else if (field.type === "number") {
1361+
const val = fm[field.key] || "";
1362+
html += `<input type="number" class="d-item-input d-item-number" data-fm-key="${esc(field.key)}" data-fm-path="${esc(filePath)}" value="${esc(val)}" placeholder="${esc(field.placeholder || "")}" min="1">`;
1363+
} else {
1364+
const val = fm[field.key] || "";
1365+
html += `<input type="text" class="d-item-input" data-fm-key="${esc(field.key)}" data-fm-path="${esc(filePath)}" value="${esc(val)}" placeholder="${esc(field.placeholder || "")}">`;
1366+
}
1367+
html += `</div>`;
1368+
}
1369+
1370+
wrap.innerHTML = html;
1371+
wrap.classList.remove("hidden");
1372+
1373+
// Bind events
1374+
wrap.querySelectorAll("select[data-fm-key]").forEach(sel => {
1375+
sel.addEventListener("change", () => saveFrontmatterField(sel.dataset.fmPath, sel.dataset.fmKey, sel.value));
1376+
});
1377+
wrap.querySelectorAll("input[data-fm-key]").forEach(inp => {
1378+
inp.addEventListener("input", () => {
1379+
const k = inp.dataset.fmKey;
1380+
clearTimeout(_itemConfigTimers[k]);
1381+
_itemConfigTimers[k] = setTimeout(() => saveFrontmatterField(inp.dataset.fmPath, inp.dataset.fmKey, inp.value), 600);
1382+
});
1383+
});
1384+
}
1385+
1386+
function parseFrontmatter(content) {
1387+
const match = content.match(/^---\n([\s\S]*?)\n---/);
1388+
if (!match) return {};
1389+
const fm = {};
1390+
for (const line of match[1].split("\n")) {
1391+
const colon = line.indexOf(":");
1392+
if (colon < 1) continue;
1393+
const key = line.slice(0, colon).trim();
1394+
const val = line.slice(colon + 1).trim();
1395+
fm[key] = val;
1396+
}
1397+
return fm;
1398+
}
1399+
1400+
async function saveFrontmatterField(filePath, key, value) {
1401+
try {
1402+
const res = await fetchJson(`/api/file-content?path=${encodeURIComponent(filePath)}`);
1403+
if (!res.ok) return;
1404+
1405+
let content = res.content;
1406+
const fmMatch = content.match(/^---\n([\s\S]*?)\n---/);
1407+
1408+
if (!fmMatch) {
1409+
const newFm = value ? `---\n${key}: ${value}\n---\n` : "";
1410+
content = newFm + content;
1411+
} else {
1412+
let fmBody = fmMatch[1];
1413+
const lineRegex = new RegExp(`^${key}:.*$`, "m");
1414+
1415+
if (value) {
1416+
if (lineRegex.test(fmBody)) {
1417+
fmBody = fmBody.replace(lineRegex, `${key}: ${value}`);
1418+
} else {
1419+
fmBody += `\n${key}: ${value}`;
1420+
}
1421+
} else {
1422+
fmBody = fmBody.replace(lineRegex, "").replace(/\n{2,}/g, "\n").trim();
1423+
}
1424+
content = `---\n${fmBody}\n---` + content.slice(fmMatch[0].length);
1425+
}
1426+
1427+
await fetchJson("/api/save-frontmatter", {
1428+
method: "POST",
1429+
headers: { "Content-Type": "application/json" },
1430+
body: JSON.stringify({ path: filePath, content }),
1431+
});
1432+
toast(`Updated ${key} for ${selectedItem?.name || "item"}`);
1433+
} catch (err) {
1434+
toast(`Failed to save: ${err.message}`, true);
1435+
}
1436+
}
1437+
12841438
function renderCcActions(item) {
12851439
const container = document.getElementById("detailCcActions");
12861440
const btnRow = document.getElementById("ccBtnRow");

src/ui/index.html

Lines changed: 39 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -79,43 +79,52 @@ <h2 id="detailTitle">Select an item</h2>
7979
</div>
8080

8181
<div class="detail-body">
82-
<div class="d-info-grid">
83-
<div class="d-info-cell">
84-
<span class="d-info-label">Scope</span>
85-
<span class="d-info-val" id="detailScope"></span>
86-
</div>
87-
<div class="d-info-cell">
88-
<span class="d-info-label">Type</span>
89-
<span class="d-info-val" id="detailType"></span>
90-
</div>
91-
<div class="d-info-cell">
92-
<span class="d-info-label">Size</span>
93-
<span class="d-info-val" id="detailSize"></span>
94-
</div>
95-
</div>
82+
<!-- Frontmatter config — dynamically populated per category -->
83+
<div class="d-item-config hidden" id="detailItemConfig"></div>
9684

97-
<div class="d-desc-wrap">
98-
<span class="d-info-label">Description</span>
99-
<div class="d-desc" id="detailDesc">Select an item to inspect its metadata and preview.</div>
85+
<!-- Collapsible metadata -->
86+
<div class="d-meta-toggle" id="detailMetaToggle">
87+
<span class="d-meta-arrow"></span> Metadata <span class="d-meta-hint">(scope, size, dates…)</span>
10088
</div>
89+
<div class="d-meta-body hidden" id="detailMetaBody">
90+
<div class="d-info-grid">
91+
<div class="d-info-cell">
92+
<span class="d-info-label">Scope</span>
93+
<span class="d-info-val" id="detailScope"></span>
94+
</div>
95+
<div class="d-info-cell">
96+
<span class="d-info-label">Type</span>
97+
<span class="d-info-val" id="detailType"></span>
98+
</div>
99+
<div class="d-info-cell">
100+
<span class="d-info-label">Size</span>
101+
<span class="d-info-val" id="detailSize"></span>
102+
</div>
103+
</div>
101104

102-
<div class="d-effective-wrap hidden" id="detailEffective">
103-
<span class="d-info-label">Why it applies</span>
104-
<div class="d-effective" id="detailEffectiveText"></div>
105-
</div>
105+
<div class="d-desc-wrap">
106+
<span class="d-info-label">Description</span>
107+
<div class="d-desc" id="detailDesc">Select an item to inspect its metadata and preview.</div>
108+
</div>
106109

107-
<div class="d-info-grid" id="detailDates">
108-
<div class="d-info-cell">
109-
<span class="d-info-label">Created</span>
110-
<span class="d-info-val"></span>
110+
<div class="d-effective-wrap hidden" id="detailEffective">
111+
<span class="d-info-label">Why it applies</span>
112+
<div class="d-effective" id="detailEffectiveText"></div>
111113
</div>
112-
<div class="d-info-cell">
113-
<span class="d-info-label">Modified</span>
114-
<span class="d-info-val"></span>
114+
115+
<div class="d-info-grid" id="detailDates">
116+
<div class="d-info-cell">
117+
<span class="d-info-label">Created</span>
118+
<span class="d-info-val"></span>
119+
</div>
120+
<div class="d-info-cell">
121+
<span class="d-info-label">Modified</span>
122+
<span class="d-info-val"></span>
123+
</div>
115124
</div>
116-
</div>
117125

118-
<div class="d-path" id="detailPath"></div>
126+
<div class="d-path" id="detailPath"></div>
127+
</div>
119128

120129
<div class="d-field d-field-preview">
121130
<div class="d-preview" id="previewContent">Select an item to preview</div>

src/ui/style.css

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1070,6 +1070,37 @@ body {
10701070
.st-ancestor { color: oklch(0.58 0.14 160); border-color: oklch(0.58 0.14 160); }
10711071
.st-shadowed { color: var(--text-muted); border-color: var(--text-muted); opacity: 0.8; }
10721072
.st-conflict { color: oklch(0.65 0.15 55); border-color: oklch(0.65 0.15 55); }
1073+
/* Universal tooltip — add data-tooltip="..." to any element */
1074+
[data-tooltip] { position: relative; cursor: help; }
1075+
[data-tooltip]::after {
1076+
content: attr(data-tooltip);
1077+
position: absolute;
1078+
top: calc(100% + 6px);
1079+
left: 0;
1080+
background: var(--surface-inset);
1081+
color: var(--text-primary);
1082+
border: 1px solid var(--border-light);
1083+
border-radius: 6px;
1084+
padding: 6px 10px;
1085+
font-size: 0.65rem;
1086+
font-weight: 400;
1087+
font-style: normal;
1088+
text-transform: none;
1089+
letter-spacing: normal;
1090+
text-decoration: none;
1091+
line-height: 1.5;
1092+
white-space: normal;
1093+
width: max-content;
1094+
max-width: 260px;
1095+
z-index: 100;
1096+
pointer-events: none;
1097+
opacity: 0;
1098+
transition: opacity 150ms;
1099+
box-shadow: 0 4px 12px rgba(0,0,0,0.3);
1100+
}
1101+
[data-tooltip]:hover::after { opacity: 1; }
1102+
1103+
/* Scope-tag overrides (keep for backward compat) */
10731104
.scope-tag[data-tooltip] { position: relative; cursor: help; }
10741105
.scope-tag[data-tooltip]::after {
10751106
content: attr(data-tooltip);
@@ -1112,6 +1143,24 @@ body {
11121143
.rule-bar-content .rule-none { opacity: 0.5; }
11131144
.d-effective-wrap { padding: 0 0 8px; }
11141145
.d-effective { font-size: 0.72rem; color: var(--text-muted); line-height: 1.5; padding: 4px 0; }
1146+
1147+
/* Item config (frontmatter fields — skills, agents, memories) */
1148+
.d-item-config { padding: 8px 0 4px; display: flex; flex-direction: column; gap: 8px; }
1149+
.d-item-config-row { display: flex; flex-direction: column; gap: 2px; }
1150+
.d-item-select, .d-item-input { font-size: 0.78rem; padding: 4px 8px; border: 1px solid var(--border-light); border-radius: 4px; background: var(--bg-secondary); color: var(--text-primary); font-family: inherit; }
1151+
.d-item-select:focus, .d-item-input:focus { outline: none; border-color: var(--accent); }
1152+
.d-item-input { width: 100%; box-sizing: border-box; }
1153+
.d-item-input::placeholder { color: var(--text-faint); font-size: 0.72rem; }
1154+
.d-item-number { width: 80px; }
1155+
1156+
/* Collapsible metadata toggle */
1157+
.d-meta-toggle { font-size: 0.7rem; font-weight: 600; color: var(--text-faint); cursor: pointer; padding: 6px 0 2px; user-select: none; letter-spacing: 0.3px; text-transform: uppercase; }
1158+
.d-meta-toggle:hover { color: var(--text-secondary); }
1159+
.d-meta-hint { font-weight: 400; font-size: 0.62rem; color: var(--text-faint); }
1160+
.d-meta-arrow { display: inline-block; transition: transform 0.15s; font-size: 0.6rem; }
1161+
.d-meta-toggle.open .d-meta-arrow { transform: rotate(90deg); }
1162+
.d-meta-body { padding: 4px 0 0; display: flex; flex-direction: column; gap: 8px; }
1163+
11151164
.theme-btn.active { background: var(--accent-light); color: var(--accent-text); border-color: var(--accent); }
11161165

11171166
.ctx-budget-summary { padding: 16px 20px; border-bottom: 1px solid var(--border-light); }

0 commit comments

Comments
 (0)