From c50ee527eaafb18e3eec397f2a67197edd3bc025 Mon Sep 17 00:00:00 2001 From: shiyue Date: Thu, 18 Jun 2026 08:42:57 +0800 Subject: [PATCH] feat: enrich Codex skill metadata display --- public/app.js | 116 ++++++++++++ public/index.html | 4 +- public/style.css | 123 ++++++++++++ scripts/regression.js | 93 ++++++++- server.js | 426 +++++++++++++++++++++++++++++++++++++++--- 5 files changed, 731 insertions(+), 31 deletions(-) diff --git a/public/app.js b/public/app.js index 98aca11..8bff990 100644 --- a/public/app.js +++ b/public/app.js @@ -3587,6 +3587,120 @@ }, ttl); } + function normalizeMentionList(value) { + return Array.isArray(value) ? value.filter((item) => item && typeof item === 'object') : []; + } + + function escapeHtmlAttr(value) { + return String(value || '') + .replace(/&/g, '&') + .replace(/"/g, '"') + .replace(//g, '>'); + } + + function shortPreviewText(text, maxLength = 140) { + const normalized = String(text || '').trim().replace(/\s+/g, ' '); + if (!normalized) return ''; + return normalized.length > maxLength ? `${normalized.slice(0, Math.max(0, maxLength - 3))}...` : normalized; + } + + function mentionDependencyStateLabel(state) { + return state === 'configured' ? '已配置' : state === 'declared' ? '未配置' : ''; + } + + function mentionDependencyLabel(dep) { + const name = dep?.value || dep?.name || dep?.server || ''; + const state = mentionDependencyStateLabel(dep?.state); + return state ? `${name} · ${state}` : name; + } + + function buildMentionTooltip(mention) { + const lines = []; + const title = mention.title || mention.name || mention.label || ''; + const description = shortPreviewText(mention.description || ''); + if (title) lines.push(title); + if (description) lines.push(description); + if (mention.defaultPromptPreview) lines.push(`默认提示: ${shortPreviewText(mention.defaultPromptPreview, 180)}`); + const dependencies = Array.isArray(mention.dependencies) ? mention.dependencies : []; + for (const dep of dependencies.slice(0, 4)) { + const label = mentionDependencyLabel(dep); + if (label) lines.push(`MCP: ${label}`); + } + return lines.join('\n'); + } + + function createMentionChip(mention) { + const chip = document.createElement('div'); + const kind = String(mention.kind || '').trim() || 'mention'; + chip.className = `msg-mention-chip kind-${kind}`; + if (mention.brandColor) chip.style.setProperty('--mention-accent', mention.brandColor); + const tooltip = buildMentionTooltip(mention); + if (tooltip) chip.title = tooltip; + + if (kind === 'skill') { + const badge = document.createElement('span'); + badge.className = 'msg-mention-badge'; + if (mention.iconSmall && /^https?:\/\//i.test(String(mention.iconSmall))) { + const img = document.createElement('img'); + img.src = mention.iconSmall; + img.alt = mention.title || mention.name || 'skill'; + img.loading = 'lazy'; + badge.appendChild(img); + } else { + badge.textContent = 'Skill'; + } + chip.appendChild(badge); + } + + const body = document.createElement('div'); + body.className = 'msg-mention-body'; + + const title = document.createElement('div'); + title.className = 'msg-mention-title'; + if (kind === 'skill') { + title.textContent = mention.title || mention.name || mention.label || ''; + } else if (kind === 'prompt') { + title.textContent = mention.title || mention.label || mention.name || ''; + } else { + title.textContent = mention.label || mention.name || ''; + } + body.appendChild(title); + + const descriptionText = mention.description || (kind === 'prompt' ? 'Prompt 模板' : ''); + if (descriptionText) { + const desc = document.createElement('div'); + desc.className = 'msg-mention-desc'; + desc.textContent = shortPreviewText(descriptionText, kind === 'skill' ? 92 : 72); + body.appendChild(desc); + } + + const dependencies = Array.isArray(mention.dependencies) ? mention.dependencies : []; + if (dependencies.length > 0) { + const meta = document.createElement('div'); + meta.className = 'msg-mention-meta'; + dependencies.slice(0, 2).forEach((dep) => { + const pill = document.createElement('span'); + pill.className = `msg-mention-pill state-${dep.state || 'declared'}`; + pill.textContent = mentionDependencyLabel(dep); + meta.appendChild(pill); + }); + body.appendChild(meta); + } + + chip.appendChild(body); + return chip; + } + + function renderComposerMentionsStrip(meta) { + const mentions = normalizeMentionList(meta?.composerMentions); + if (mentions.length === 0) return null; + const wrap = document.createElement('div'); + wrap.className = 'msg-mentions'; + mentions.forEach((mention) => wrap.appendChild(createMentionChip(mention))); + return wrap; + } + function createMsgElement(role, content, attachments = [], meta = {}) { const div = document.createElement('div'); const isCrossConversation = !!meta.crossConversation; @@ -3705,6 +3819,8 @@ if (attachments.length > 0) { bubble.insertAdjacentHTML('beforeend', renderAttachmentPreviews(attachments)); } + const mentionsStrip = renderComposerMentionsStrip(meta); + if (mentionsStrip) bubble.appendChild(mentionsStrip); } else { renderAssistantContent(bubble, content); if (attachments.length > 0) { diff --git a/public/index.html b/public/index.html index 8c72ae9..fc64209 100644 --- a/public/index.html +++ b/public/index.html @@ -14,7 +14,7 @@ document.documentElement.dataset.dividerTime = dividerTime; })(); - + @@ -150,6 +150,6 @@ - + diff --git a/public/style.css b/public/style.css index 90324cb..a7f8998 100644 --- a/public/style.css +++ b/public/style.css @@ -1903,6 +1903,105 @@ body.session-loading-active { border-bottom-right-radius: 4px; padding-right: 42px; } +.msg-mentions { + display: flex; + flex-wrap: wrap; + gap: 8px; + margin-top: 10px; +} +.msg-mention-chip { + --mention-accent: rgba(255, 255, 255, 0.34); + display: flex; + flex-direction: column; + align-items: stretch; + gap: 5px; + max-width: 100%; + min-width: 0; + padding: 7px 9px 8px; + border: 1px solid rgba(255, 255, 255, 0.16); + border-radius: 12px; + background: rgba(255, 255, 255, 0.1); + color: inherit; +} +.msg-mention-chip.kind-skill { + width: min(290px, 100%); +} +.msg-mention-chip.kind-prompt, +.msg-mention-chip.kind-file { + background: rgba(255, 255, 255, 0.08); +} +.msg-mention-badge { + min-width: 0; + height: 18px; + width: auto; + flex: 0 0 auto; + display: inline-flex; + align-items: center; + justify-content: center; + align-self: flex-start; + padding: 0 7px; + border-radius: 6px; + background: color-mix(in srgb, var(--mention-accent) 68%, rgba(255, 255, 255, 0.08)); + color: #fff; + font-size: 9px; + font-weight: 800; + line-height: 1; + text-transform: uppercase; + letter-spacing: 0; + overflow: hidden; +} +.msg-mention-badge img { + display: block; + width: 18px; + height: 18px; + margin: 0 -7px; + object-fit: cover; +} +.msg-mention-body { + min-width: 0; + display: flex; + flex-direction: column; + gap: 2px; +} +.msg-mention-title { + font-size: 12px; + font-weight: 800; + line-height: 1.25; + overflow-wrap: anywhere; +} +.msg-mention-desc { + font-size: 11px; + line-height: 1.3; + color: rgba(255, 255, 255, 0.84); + overflow-wrap: anywhere; +} +.msg-mention-meta { + display: flex; + flex-wrap: wrap; + gap: 6px; + margin-top: 2px; +} +.msg-mention-pill { + display: inline-flex; + align-items: center; + max-width: 100%; + padding: 2px 6px; + border-radius: 999px; + border: 1px solid rgba(255, 255, 255, 0.18); + background: rgba(255, 255, 255, 0.08); + color: rgba(255, 255, 255, 0.88); + font-size: 10px; + font-weight: 700; + line-height: 1.2; + overflow-wrap: anywhere; +} +.msg-mention-pill.state-configured { + border-color: rgba(255, 255, 255, 0.28); + background: rgba(93, 138, 84, 0.24); +} +.msg-mention-pill.state-declared { + background: rgba(255, 255, 255, 0.06); +} .msg.user.codex-steer-message .msg-bubble { padding-bottom: 10px; } @@ -2051,6 +2150,30 @@ body.session-loading-active { border-bottom-left-radius: 4px; color: var(--text-primary); } +.msg.assistant .msg-mention-chip { + border-color: rgba(48, 62, 82, 0.14); + background: rgba(91, 126, 161, 0.07); + color: var(--text-primary); +} +.msg.assistant .msg-mention-chip.kind-prompt, +.msg.assistant .msg-mention-chip.kind-file { + background: rgba(91, 126, 161, 0.05); +} +.msg.assistant .msg-mention-badge { + color: #fff; +} +.msg.assistant .msg-mention-desc { + color: var(--text-secondary); +} +.msg.assistant .msg-mention-pill { + border-color: rgba(48, 62, 82, 0.14); + background: rgba(91, 126, 161, 0.08); + color: var(--text-secondary); +} +.msg.assistant .msg-mention-pill.state-configured { + background: rgba(93, 138, 84, 0.14); + color: var(--success); +} .note-meta { margin-bottom: 6px; color: var(--note-accent); diff --git a/scripts/regression.js b/scripts/regression.js index cfc2b46..3570ccd 100644 --- a/scripts/regression.js +++ b/scripts/regression.js @@ -134,6 +134,7 @@ function connectWs(port, password) { return new Promise((resolve, reject) => { const ws = new WebSocket(`ws://127.0.0.1:${port}/ws`); const messages = []; + let settled = false; ws.on('open', () => { ws.send(JSON.stringify({ type: 'auth', password })); @@ -141,10 +142,20 @@ function connectWs(port, password) { ws.on('message', (buf) => { const msg = JSON.parse(String(buf)); messages.push(msg); - if (msg.type === 'auth_result' && msg.success) resolve({ ws, messages, token: msg.token }); - if (msg.type === 'auth_result' && !msg.success) reject(new Error('Auth failed')); + if (msg.type === 'auth_result' && msg.success) { + settled = true; + resolve({ ws, messages, token: msg.token }); + } + if (msg.type === 'auth_result' && !msg.success) { + settled = true; + reject(new Error('Auth failed')); + } + }); + ws.on('error', (err) => { + if (settled) return; + settled = true; + reject(err); }); - ws.on('error', reject); }); } @@ -500,6 +511,8 @@ function assertFrontendComposerMcpContract() { assert(menuStart >= 0 && menuEnd > menuStart, 'Frontend should define showCmdMenu before requestComposerSuggestions'); const menuBlock = source.slice(menuStart, menuEnd); assert(/item\.kind\s*===\s*'mcp'/.test(menuBlock) && menuBlock.includes("'MCP'"), 'Composer menu should render MCP item labels'); + assert(source.includes('function renderComposerMentionsStrip(meta)'), 'Frontend should define composer mention strip renderer'); + assert(source.includes("className = 'msg-mentions'"), 'Frontend should render a dedicated mention strip container'); } async function main() { @@ -537,6 +550,39 @@ async function main() { '', 'Use this only in regression tests.', ].join('\n')); + mkdirp(path.join(skillDir, 'agents')); + fs.writeFileSync(path.join(skillDir, 'agents', 'openai.yaml'), [ + 'interface:', + ' display_name: "Regression Docs"', + ' short_description: "Regression skill metadata for composer suggestions."', + ' brand_color: "#2f6f64"', + ' default_prompt: "Use Regression Docs for regression metadata coverage."', + '', + 'dependencies:', + ' tools:', + ' - type: "mcp"', + ' value: "openaiDeveloperDocs"', + ' description: "Regression docs MCP server"', + ' transport: "streamable_http"', + ' url: "https://developers.openai.com/mcp"', + ].join('\n')); + const codexPromptsDir = path.join(homeDir, '.codex', 'prompts'); + mkdirp(path.join(codexPromptsDir, 'nested-tool')); + fs.writeFileSync(path.join(codexPromptsDir, 'quick-note.md'), [ + '---', + 'title: Quick Note', + 'description: Prompt file shortcut from ~/.codex/prompts.', + '---', + '', + 'Prompt body from @quick-note.', + ].join('\n')); + fs.writeFileSync(path.join(codexPromptsDir, 'nested-tool', 'prompt.md'), [ + '---', + 'description: Directory-style prompt shortcut.', + '---', + '', + 'Prompt body from @nested-tool.', + ].join('\n')); fs.writeFileSync(path.join(configDir, 'prompts.json'), JSON.stringify({ prompts: [ { @@ -618,6 +664,18 @@ async function main() { const codexInitCwd = path.join(tempRoot, 'codex-space'); mkdirp(codexInitCwd); + const projectSkillDir = path.join(codexInitCwd, '.agents', 'skills', 'project-skill'); + mkdirp(projectSkillDir); + fs.writeFileSync(path.join(projectSkillDir, 'SKILL.md'), [ + '---', + 'name: project-skill', + 'description: Project-scoped skill for composer suggestions.', + '---', + '', + '# Project Skill', + '', + 'Use this only in regression tests.', + ].join('\n')); fs.writeFileSync(path.join(codexInitCwd, 'context.txt'), 'Composer file context body.'); ws.send(JSON.stringify({ type: 'new_session', agent: 'codex', cwd: codexInitCwd, mode: 'plan' })); const codexSession = await nextMessage(messages, ws, (msg) => msg.type === 'session_info' && msg.agent === 'codex' && msg.cwd === codexInitCwd); @@ -667,15 +725,33 @@ async function main() { ws.send(JSON.stringify({ type: 'composer_suggestions', requestId: 'reg-skill', trigger: '$', query: 'reg', sessionId: codexSession.sessionId, agent: 'codex' })); const skillComposer = await nextMessage(messages, ws, (msg) => msg.type === 'composer_suggestions' && msg.requestId === 'reg-skill'); assert(skillComposer.items.some((item) => item.kind === 'skill' && item.name === 'regression-skill'), 'Composer skill suggestions should include local Codex skill'); + const metadataSkill = skillComposer.items.find((item) => item.kind === 'skill' && item.name === 'regression-skill'); + assert(metadataSkill?.title === 'Regression Docs', 'Composer skill suggestions should expose openai.yaml display_name'); + assert(/metadata coverage/.test(metadataSkill?.defaultPromptPreview || ''), 'Composer skill suggestions should expose default prompt preview'); + + ws.send(JSON.stringify({ type: 'composer_suggestions', requestId: 'reg-project-skill', trigger: '$', query: 'project', sessionId: codexSession.sessionId, agent: 'codex' })); + const projectSkillComposer = await nextMessage(messages, ws, (msg) => msg.type === 'composer_suggestions' && msg.requestId === 'reg-project-skill'); + assert(projectSkillComposer.items.some((item) => item.kind === 'skill' && item.name === 'project-skill'), 'Composer skill suggestions should include project-scoped skill from session cwd'); ws.send(JSON.stringify({ type: 'composer_suggestions', requestId: 'reg-skill-mcp', trigger: '$', query: 'ccweb', sessionId: codexSession.sessionId, agent: 'codex' })); const skillMcpComposer = await nextMessage(messages, ws, (msg) => msg.type === 'composer_suggestions' && msg.requestId === 'reg-skill-mcp'); assert(skillMcpComposer.items.some((item) => item.kind === 'mcp' && item.name === 'ccweb_list_conversations'), 'Composer skill trigger suggestions should include ccweb MCP tools'); + ws.send(JSON.stringify({ type: 'composer_suggestions', requestId: 'reg-skill-declared-mcp', trigger: '$', query: 'openai', sessionId: codexSession.sessionId, agent: 'codex' })); + const skillDeclaredMcpComposer = await nextMessage(messages, ws, (msg) => msg.type === 'composer_suggestions' && msg.requestId === 'reg-skill-declared-mcp'); + assert(skillDeclaredMcpComposer.items.some((item) => item.kind === 'mcp' && item.name === 'openaiDeveloperDocs' && item.state === 'declared'), 'Composer skill trigger suggestions should include declared MCP dependencies from openai.yaml'); ws.send(JSON.stringify({ type: 'composer_suggestions', requestId: 'reg-prompt', trigger: '@', query: 'ship', sessionId: codexSession.sessionId, agent: 'codex' })); const promptComposer = await nextMessage(messages, ws, (msg) => msg.type === 'composer_suggestions' && msg.requestId === 'reg-prompt'); assert(promptComposer.items.some((item) => item.kind === 'prompt' && item.name === 'shipit'), 'Composer prompt suggestions should include configured prompt'); + ws.send(JSON.stringify({ type: 'composer_suggestions', requestId: 'reg-prompt-file', trigger: '@', query: 'quick', sessionId: codexSession.sessionId, agent: 'codex' })); + const promptFileComposer = await nextMessage(messages, ws, (msg) => msg.type === 'composer_suggestions' && msg.requestId === 'reg-prompt-file'); + assert(promptFileComposer.items.some((item) => item.kind === 'prompt' && item.name === 'quick-note'), 'Composer prompt suggestions should include ~/.codex/prompts/*.md shortcuts'); + + ws.send(JSON.stringify({ type: 'composer_suggestions', requestId: 'reg-prompt-dir', trigger: '@', query: 'nested', sessionId: codexSession.sessionId, agent: 'codex' })); + const promptDirComposer = await nextMessage(messages, ws, (msg) => msg.type === 'composer_suggestions' && msg.requestId === 'reg-prompt-dir'); + assert(promptDirComposer.items.some((item) => item.kind === 'prompt' && item.name === 'nested-tool'), 'Composer prompt suggestions should include ~/.codex/prompts//prompt.md shortcuts'); + ws.send(JSON.stringify({ type: 'composer_suggestions', requestId: 'reg-prompt-mcp', trigger: '@', query: 'ccweb', sessionId: codexSession.sessionId, agent: 'codex' })); const promptMcpComposer = await nextMessage(messages, ws, (msg) => msg.type === 'composer_suggestions' && msg.requestId === 'reg-prompt-mcp'); assert(promptMcpComposer.items.some((item) => item.kind === 'mcp' && item.name === 'ccweb_list_conversations'), 'Composer prompt trigger suggestions should include ccweb MCP tools'); @@ -686,7 +762,7 @@ async function main() { ws.send(JSON.stringify({ type: 'message', - text: '@shipit @context.txt $regression-skill run composer regression', + text: '@shipit @quick-note @context.txt $regression-skill $project-skill run composer regression', sessionId: codexSession.sessionId, mode: 'plan', agent: 'codex', @@ -694,16 +770,23 @@ async function main() { const composerExpanded = await nextMessage(messages, ws, (msg) => ( msg.type === 'text_delta' && /BEGIN CC-WEB PROMPT: shipit/.test(msg.text || '') && + /BEGIN CC-WEB PROMPT: quick-note/.test(msg.text || '') && /Composer file context body/.test(msg.text || '') )); assert(/Regression prompt body from @shipit/.test(composerExpanded.text || ''), 'Composer runtime prompt should expand @prompt content'); + assert(/Prompt body from @quick-note/.test(composerExpanded.text || ''), 'Composer runtime prompt should expand ~/.codex/prompts prompt shortcuts'); await nextMessage(messages, ws, (msg) => msg.type === 'done' && msg.sessionId === codexSession.sessionId); const storedComposerSession = JSON.parse(fs.readFileSync(path.join(sessionsDir, `${codexSession.sessionId}.json`), 'utf8')); - const storedComposerMessage = storedComposerSession.messages.find((message) => message.content === '@shipit @context.txt $regression-skill run composer regression'); + const storedComposerMessage = storedComposerSession.messages.find((message) => message.content === '@shipit @quick-note @context.txt $regression-skill $project-skill run composer regression'); assert(storedComposerMessage, 'Composer message should persist original user text'); assert(storedComposerMessage.composerMentions?.some((mention) => mention.kind === 'prompt' && mention.name === 'shipit'), 'Composer message should persist prompt mention metadata'); + assert(storedComposerMessage.composerMentions?.some((mention) => mention.kind === 'prompt' && mention.name === 'quick-note'), 'Composer message should persist ~/.codex/prompts prompt mention metadata'); assert(storedComposerMessage.composerMentions?.some((mention) => mention.kind === 'file' && mention.name === 'context.txt'), 'Composer message should persist file mention metadata'); assert(storedComposerMessage.composerMentions?.some((mention) => mention.kind === 'skill' && mention.name === 'regression-skill'), 'Composer message should persist skill mention metadata'); + assert(storedComposerMessage.composerMentions?.some((mention) => mention.kind === 'skill' && mention.name === 'project-skill'), 'Composer message should persist project-scoped skill mention metadata'); + const storedRegressionSkillMention = storedComposerMessage.composerMentions?.find((mention) => mention.kind === 'skill' && mention.name === 'regression-skill'); + assert(storedRegressionSkillMention?.title === 'Regression Docs', 'Stored skill mention should persist display title from openai.yaml'); + assert(storedRegressionSkillMention?.dependencies?.some((dep) => dep.value === 'openaiDeveloperDocs' && dep.state === 'declared'), 'Stored skill mention should persist MCP dependency metadata'); const mcpList = await callInternalMcp(port, internalMcpToken, { tool: 'ccweb_list_conversations', diff --git a/server.js b/server.js index b58747c..f929e03 100644 --- a/server.js +++ b/server.js @@ -48,6 +48,9 @@ const COMPOSER_SUGGESTION_LIMIT = 20; const COMPOSER_FILE_CONTEXT_MAX_BYTES = 60 * 1024; const COMPOSER_MAX_FILE_MENTIONS = 4; const COMPOSER_MAX_PROMPT_MENTIONS = 4; +const COMPOSER_SKILL_METADATA_MAX_BYTES = 64 * 1024; +const COMPOSER_SKILL_DEFAULT_PROMPT_PREVIEW_CHARS = 180; +const COMPOSER_SKILL_TEXT_MAX_CHARS = 240; const SESSION_MAX_JSON_BYTES = readPositiveIntEnv('CC_WEB_SESSION_MAX_JSON_BYTES', 10 * 1024 * 1024, { min: 512 * 1024 }); const SESSION_LOAD_MAX_BYTES = readPositiveIntEnv('CC_WEB_SESSION_LOAD_MAX_BYTES', 32 * 1024 * 1024, { min: SESSION_MAX_JSON_BYTES }); const SESSION_META_FULL_PARSE_MAX_BYTES = readPositiveIntEnv('CC_WEB_SESSION_META_FULL_PARSE_MAX_BYTES', 512 * 1024, { min: 64 * 1024 }); @@ -1514,6 +1517,156 @@ function parseSimpleFrontmatter(text) { return { meta, body: raw.slice(match[0].length) }; } +function stripSimpleYamlComment(line) { + const value = String(line || ''); + let quote = ''; + let escaped = false; + for (let i = 0; i < value.length; i += 1) { + const ch = value[i]; + if (quote) { + if (escaped) { + escaped = false; + continue; + } + if (ch === '\\' && quote === '"') { + escaped = true; + continue; + } + if (ch === quote) quote = ''; + continue; + } + if (ch === '"' || ch === '\'') { + quote = ch; + continue; + } + if (ch === '#' && (i === 0 || /\s/.test(value[i - 1]))) { + return value.slice(0, i).trimEnd(); + } + } + return value; +} + +function parseSimpleYamlScalar(rawValue) { + const value = stripSimpleYamlComment(rawValue).trim(); + if (!value) return ''; + if (value === 'true') return true; + if (value === 'false') return false; + if (value === 'null' || value === '~') return null; + if (value.startsWith('"') && value.endsWith('"')) { + try { return JSON.parse(value); } catch { return value.slice(1, -1); } + } + if (value.startsWith('\'') && value.endsWith('\'')) { + return value.slice(1, -1).replace(/''/g, '\''); + } + return value; +} + +function parseSimpleYamlKeyValue(text) { + const value = String(text || '').trim(); + if (!value) return null; + let quote = ''; + let escaped = false; + for (let i = 0; i < value.length; i += 1) { + const ch = value[i]; + if (quote) { + if (escaped) { + escaped = false; + continue; + } + if (ch === '\\' && quote === '"') { + escaped = true; + continue; + } + if (ch === quote) quote = ''; + continue; + } + if (ch === '"' || ch === '\'') { + quote = ch; + continue; + } + if (ch !== ':') continue; + const key = value.slice(0, i).trim(); + if (!/^[A-Za-z0-9_-]+$/.test(key)) return null; + return { key, value: parseSimpleYamlScalar(value.slice(i + 1)) }; + } + return null; +} + +function parseOpenAiSkillYaml(text) { + const parsed = { interface: {}, policy: {}, dependencies: { tools: [] } }; + let section = ''; + let dependencyList = ''; + let currentTool = null; + + for (const rawLine of String(text || '').split(/\r?\n/)) { + const line = stripSimpleYamlComment(rawLine); + if (!line.trim()) continue; + const indent = line.match(/^\s*/)?.[0]?.length || 0; + const trimmed = line.trim(); + + if (indent === 0) { + const sectionName = trimmed.endsWith(':') ? trimmed.slice(0, -1).trim() : ''; + if (['interface', 'policy', 'dependencies'].includes(sectionName)) { + section = sectionName; + dependencyList = ''; + currentTool = null; + } else { + section = ''; + } + continue; + } + + if (!section) continue; + + if (section === 'dependencies') { + if (indent === 2 && trimmed === 'tools:') { + dependencyList = 'tools'; + currentTool = null; + continue; + } + if (dependencyList === 'tools' && indent >= 4 && trimmed.startsWith('-')) { + currentTool = {}; + parsed.dependencies.tools.push(currentTool); + const rest = trimmed.slice(1).trim(); + const item = parseSimpleYamlKeyValue(rest); + if (item) currentTool[item.key] = item.value; + continue; + } + if (dependencyList === 'tools' && currentTool && indent >= 6) { + const item = parseSimpleYamlKeyValue(trimmed); + if (item) currentTool[item.key] = item.value; + } + continue; + } + + if (indent >= 2) { + const item = parseSimpleYamlKeyValue(trimmed); + if (item) parsed[section][item.key] = item.value; + } + } + + return parsed; +} + +function normalizeComposerTextValue(value, maxChars = COMPOSER_SKILL_TEXT_MAX_CHARS) { + const text = String(value || '').trim().replace(/\s+/g, ' '); + return text ? truncateTextValue(text, maxChars, '...') : ''; +} + +function normalizeSkillBrandColor(value) { + const color = String(value || '').trim(); + return /^#[0-9A-Fa-f]{3}(?:[0-9A-Fa-f]{3})?$/.test(color) ? color : ''; +} + +function normalizeSkillIconUrl(value) { + const url = String(value || '').trim(); + if (/^https?:\/\//i.test(url)) return url; + if (/^data:image\/(?:png|jpe?g|gif|webp|svg\+xml);base64,[A-Za-z0-9+/=]+$/i.test(url) && url.length <= 12000) { + return url; + } + return ''; +} + function normalizeComposerKey(value) { return String(value || '').trim().replace(/^[@$]/, '').toLowerCase(); } @@ -1551,19 +1704,162 @@ function collectFilesByName(rootDir, fileNames, options = {}) { return found; } -function composerSkillRoots() { +function collectFilesByExtension(rootDir, extensions, options = {}) { + const maxDepth = Number.isFinite(options.maxDepth) ? options.maxDepth : 4; + const maxFiles = Number.isFinite(options.maxFiles) ? options.maxFiles : 200; + const ignoreDirs = new Set(['node_modules', '.git', 'sessions', 'logs']); + const normalizedExtensions = new Set( + [...extensions].map((ext) => String(ext || '').trim().toLowerCase()).filter(Boolean) + ); + const found = []; + + function walk(dir, depth) { + if (found.length >= maxFiles || depth > maxDepth) return; + let entries; + try { + entries = fs.readdirSync(dir, { withFileTypes: true }); + } catch { + return; + } + entries.sort((a, b) => a.name.localeCompare(b.name, 'zh-CN', { numeric: true, sensitivity: 'base' })); + for (const entry of entries) { + if (found.length >= maxFiles) return; + const entryPath = path.join(dir, entry.name); + if (entry.isFile() && normalizedExtensions.has(path.extname(entry.name).toLowerCase())) { + found.push(entryPath); + } else if (entry.isDirectory() && !ignoreDirs.has(entry.name)) { + walk(entryPath, depth + 1); + } + } + } + + try { + const realRoot = fs.realpathSync(rootDir); + if (fs.statSync(realRoot).isDirectory()) walk(realRoot, 0); + } catch {} + return found; +} + +function hasProjectRootMarker(dir) { + return ['.git', '.hg', '.sl'].some((marker) => { + try { return fs.existsSync(path.join(dir, marker)); } catch { return false; } + }); +} + +function findNearestProjectRoot(startDir) { + const start = normalizeExistingDirPath(startDir); + if (!start) return null; + let dir = start; + const seen = new Set(); + while (dir && !seen.has(dir)) { + seen.add(dir); + if (hasProjectRootMarker(dir)) return dir; + const parent = path.dirname(dir); + if (!parent || parent === dir) break; + dir = parent; + } + return start; +} + +function composerProjectSkillRoots(cwd) { + const start = normalizeExistingDirPath(cwd); + const projectRoot = findNearestProjectRoot(start); + if (!start || !projectRoot) return []; const roots = []; - const codexHome = getCodexHomeDir(); - if (codexHome) roots.push(path.join(codexHome, 'skills')); - roots.push(path.join(__dirname, '.codex', 'skills')); - roots.push(path.join(__dirname, '.agents', 'skills')); + let dir = start; + const seen = new Set(); + while (dir && !seen.has(dir)) { + seen.add(dir); + roots.push(path.join(dir, '.agents', 'skills')); + roots.push(path.join(dir, '.codex', 'skills')); + if (dir === projectRoot) break; + const parent = path.dirname(dir); + if (!parent || parent === dir) break; + dir = parent; + } return roots; } -function loadCodexSkills() { +function composerSkillRoots(options = {}) { + const roots = []; + const session = options.session || (options.sessionId ? loadSession(options.sessionId) : null); + roots.push(...composerProjectSkillRoots(options.cwd || session?.cwd)); + const codexHome = getCodexHomeDir(); + if (codexHome) roots.push(path.join(codexHome, 'skills')); + const homeDir = normalizeExistingDirPath(process.env.HOME || process.env.USERPROFILE || ''); + if (homeDir) roots.push(path.join(homeDir, '.agents', 'skills')); + roots.push(path.join(__dirname, '.codex', 'skills')); + roots.push(path.join(__dirname, '.agents', 'skills')); + roots.push('/etc/codex/skills'); + const seen = new Set(); + return roots.filter((root) => { + const key = path.resolve(String(root || '')); + if (!key || seen.has(key)) return false; + seen.add(key); + return true; + }); +} + +function normalizeSkillMcpUrl(value) { + const raw = String(value || '').trim(); + if (!raw) return ''; + try { + const parsed = new URL(raw); + return ['http:', 'https:'].includes(parsed.protocol) ? parsed.toString() : ''; + } catch { + return ''; + } +} + +function normalizeSkillMcpTool(rawTool) { + if (!rawTool || typeof rawTool !== 'object') return null; + const type = String(rawTool.type || '').trim().toLowerCase(); + const value = normalizeMcpServerName(rawTool.value || rawTool.name || rawTool.server); + if (type !== 'mcp' || !isLikelyMcpServerName(value)) return null; + return { + type: 'mcp', + value, + name: value, + server: value, + description: normalizeComposerTextValue(rawTool.description || `MCP server: ${value}`), + transport: normalizeComposerTextValue(rawTool.transport, 80), + url: normalizeSkillMcpUrl(rawTool.url), + }; +} + +function loadOpenAiSkillMetadata(skillPath) { + const skillDir = path.dirname(skillPath); + const metadataPath = path.join(skillDir, 'agents', 'openai.yaml'); + try { + const stat = fs.statSync(metadataPath); + if (!stat.isFile() || stat.size > COMPOSER_SKILL_METADATA_MAX_BYTES) return null; + const parsed = parseOpenAiSkillYaml(fs.readFileSync(metadataPath, 'utf8')); + const iface = parsed.interface || {}; + const policy = parsed.policy || {}; + const tools = Array.isArray(parsed.dependencies?.tools) ? parsed.dependencies.tools : []; + const mcpTools = tools.map((tool) => normalizeSkillMcpTool(tool)).filter(Boolean); + return { + metadataSource: path.relative(process.cwd(), metadataPath) || metadataPath, + displayName: normalizeComposerTextValue(iface.display_name, 100), + shortDescription: normalizeComposerTextValue(iface.short_description, COMPOSER_SKILL_TEXT_MAX_CHARS), + iconSmall: normalizeSkillIconUrl(iface.icon_small), + iconLarge: normalizeSkillIconUrl(iface.icon_large), + brandColor: normalizeSkillBrandColor(iface.brand_color), + defaultPromptPreview: normalizeComposerTextValue(iface.default_prompt, COMPOSER_SKILL_DEFAULT_PROMPT_PREVIEW_CHARS), + policy: { + allowImplicitInvocation: policy.allow_implicit_invocation !== false, + }, + dependencies: { tools: mcpTools }, + }; + } catch { + return null; + } +} + +function loadCodexSkills(options = {}) { const seen = new Set(); const skills = []; - for (const root of composerSkillRoots()) { + for (const root of composerSkillRoots(options)) { for (const skillPath of collectFilesByName(root, new Set(['SKILL.md']), { maxDepth: 5, maxFiles: 300 })) { let text = ''; try { text = fs.readFileSync(skillPath, 'utf8'); } catch { continue; } @@ -1574,13 +1870,24 @@ function loadCodexSkills() { const key = normalizeComposerKey(name); if (!key || seen.has(key)) continue; seen.add(key); + const metadata = loadOpenAiSkillMetadata(skillPath); + const title = metadata?.displayName || name; + const description = metadata?.shortDescription || String(meta.description || 'Codex skill').trim(); skills.push({ kind: 'skill', name, label: `$${name}`, - description: String(meta.description || 'Codex skill').trim(), + title, + description, insertion: `$${name}`, source: path.relative(process.cwd(), skillPath) || skillPath, + metadataSource: metadata?.metadataSource || '', + iconSmall: metadata?.iconSmall || '', + iconLarge: metadata?.iconLarge || '', + brandColor: metadata?.brandColor || '', + defaultPromptPreview: metadata?.defaultPromptPreview || '', + policy: metadata?.policy || { allowImplicitInvocation: true }, + dependencies: metadata?.dependencies || { tools: [] }, }); } } @@ -1627,19 +1934,19 @@ function loadConfiguredPrompts() { function loadPromptFiles() { const prompts = []; for (const root of composerPromptRoots()) { - const files = collectFilesByName(root, new Set(['prompt.md', 'PROMPT.md']), { maxDepth: 5, maxFiles: 200 }); - try { - if (fs.existsSync(root)) { - for (const entry of fs.readdirSync(root, { withFileTypes: true })) { - if (entry.isFile() && /\.(md|txt)$/i.test(entry.name)) files.push(path.join(root, entry.name)); - } - } - } catch {} + const files = Array.from(new Set([ + ...collectFilesByName(root, new Set(['prompt.md', 'PROMPT.md']), { maxDepth: 5, maxFiles: 300 }), + ...collectFilesByExtension(root, new Set(['.md', '.txt']), { maxDepth: 5, maxFiles: 300 }), + ])); for (const filePath of files) { let text = ''; try { text = fs.readFileSync(filePath, 'utf8'); } catch { continue; } const { meta, body } = parseSimpleFrontmatter(text); - const fallbackName = path.basename(filePath).replace(/\.(md|txt)$/i, ''); + const baseName = path.basename(filePath).replace(/\.(md|txt)$/i, ''); + const relName = normalizeRelativeBrowserPath(path.relative(root, filePath)).replace(/\.(md|txt)$/i, ''); + const fallbackName = /^prompt$/i.test(baseName) + ? path.basename(path.dirname(filePath)) + : relName; const prompt = normalizePromptRecord({ name: meta.name || fallbackName, title: meta.title || meta.name || fallbackName, @@ -1754,17 +2061,69 @@ function mcpServerSuggestion(name, options = {}) { server, source: options.source || 'mcp', itemType: 'server', + state: options.state || (options.source === 'skill-dependency' ? 'declared' : 'configured'), + dependencyOf: options.dependencyOf || '', + transport: options.transport || '', + url: options.url || '', }; } -function listComposerMcpItems(sessionId) { +function summarizeSkillDependencies(skill) { + const tools = Array.isArray(skill?.dependencies?.tools) ? skill.dependencies.tools : []; + return tools + .filter((tool) => tool?.type === 'mcp' && isLikelyMcpServerName(tool.value || tool.name || tool.server)) + .map((tool) => ({ + type: 'mcp', + value: normalizeMcpServerName(tool.value || tool.name || tool.server), + name: normalizeMcpServerName(tool.value || tool.name || tool.server), + description: normalizeComposerTextValue(tool.description || ''), + transport: normalizeComposerTextValue(tool.transport || '', 80), + url: normalizeSkillMcpUrl(tool.url || ''), + state: 'declared', + })); +} + +function summarizeSkillForMention(skill, configuredMcpNames = new Set()) { + const dependencies = summarizeSkillDependencies(skill).map((dependency) => ({ + ...dependency, + state: configuredMcpNames.has(normalizeMcpServerName(dependency.value)) ? 'configured' : 'declared', + })); + return { + kind: 'skill', + name: skill.name, + label: `$${skill.name}`, + title: skill.title || skill.name, + description: skill.description || '', + source: skill.source || '', + metadataSource: skill.metadataSource || '', + iconSmall: skill.iconSmall || '', + iconLarge: skill.iconLarge || '', + brandColor: skill.brandColor || '', + defaultPromptPreview: skill.defaultPromptPreview || '', + dependencies, + }; +} + +function listComposerMcpItems(options = {}) { + const sessionId = typeof options === 'string' ? options : options.sessionId; + const agent = typeof options === 'object' ? options.agent : ''; + const skills = typeof options === 'object' && Array.isArray(options.skills) + ? options.skills + : isCodexLikeAgent(agent) + ? loadCodexSkills(options) + : []; const items = []; const seen = new Set(); + const configured = new Set(); const push = (item) => { if (!item?.server && !item?.name) return; const key = `${item.kind || ''}:${item.server || ''}:${item.name || item.label || item.insertion || ''}`; if (seen.has(key)) return; seen.add(key); + if (item.kind === 'mcp' && item.itemType === 'server' && item.state !== 'declared') { + const server = normalizeMcpServerName(item.server || item.name); + if (server) configured.add(server); + } items.push(item); }; @@ -1794,6 +2153,20 @@ function listComposerMcpItems(sessionId) { itemType: 'tool', }); } + for (const skill of skills) { + for (const tool of summarizeSkillDependencies(skill)) { + const server = normalizeMcpServerName(tool.value || tool.name || tool.server); + if (!server || configured.has(server)) continue; + push(mcpServerSuggestion(server, { + source: 'skill-dependency', + state: 'declared', + dependencyOf: skill.name, + description: tool.description || `MCP server: ${server}`, + transport: tool.transport || '', + url: tool.url || '', + })); + } + } return items; } @@ -1864,8 +2237,9 @@ function listComposerFileSuggestions(sessionId, query) { return items.slice(0, COMPOSER_SUGGESTION_LIMIT); } -function listComposerSuggestions(trigger, query, sessionId, agent) { - const mcpItems = filterComposerItems(listComposerMcpItems(sessionId), query); +function listComposerSuggestions(trigger, query, sessionId, agent, session = null) { + const skillItems = isCodexLikeAgent(agent) ? loadCodexSkills({ session }) : []; + const mcpItems = filterComposerItems(listComposerMcpItems({ sessionId, agent, skills: skillItems }), query); if (trigger === '/') { const commands = filterComposerItems(COMPOSER_COMMANDS.map((cmd) => ({ kind: 'command', @@ -1877,7 +2251,7 @@ function listComposerSuggestions(trigger, query, sessionId, agent) { return mergeComposerSuggestionGroups(commands, mcpItems); } if (trigger === '$') { - const skills = isCodexLikeAgent(agent) ? filterComposerItems(loadCodexSkills(), query) : []; + const skills = filterComposerItems(skillItems, query); return mergeComposerSuggestionGroups(mcpItems, skills); } if (trigger === '@') { @@ -1905,7 +2279,8 @@ function handleComposerSuggestions(ws, msg) { } const sessionId = sanitizeId(msg.sessionId || ''); const agent = normalizeAgent(msg.agent); - const items = listComposerSuggestions(trigger, query, sessionId, agent); + const session = sessionId ? loadSession(sessionId) : null; + const items = listComposerSuggestions(trigger, query, sessionId, agent, session); return wsSend(ws, { type: 'composer_suggestions', requestId, trigger, query, items }); } @@ -1969,13 +2344,16 @@ function resolveComposerDecorators(text, session, agent) { } const promptsByName = getComposerPromptMap(); - const skillsByName = new Map(loadCodexSkills().map((skill) => [normalizeComposerKey(skill.name), skill])); + const skillsByName = new Map(loadCodexSkills({ session }).map((skill) => [normalizeComposerKey(skill.name), skill])); const mentions = []; const promptBlocks = []; const fileBlocks = []; const seenPromptNames = new Set(); const seenFilePaths = new Set(); const seenSkillNames = new Set(); + const configuredMcpNames = new Set(listComposerMcpItems({ sessionId: session?.id, agent, skills: [...skillsByName.values()] }) + .filter((item) => item.kind === 'mcp' && item.itemType === 'server' && item.state !== 'declared') + .map((item) => normalizeMcpServerName(item.server || item.name))); const re = /(^|\s)([@$])([^\s]+)/g; let match; @@ -1989,7 +2367,7 @@ function resolveComposerDecorators(text, session, agent) { const skill = skillsByName.get(key); if (skill && !seenSkillNames.has(key)) { seenSkillNames.add(key); - mentions.push({ kind: 'skill', name: skill.name, label: `$${skill.name}`, source: skill.source || '' }); + mentions.push(summarizeSkillForMention(skill, configuredMcpNames)); } continue; }