在网页端使用skill

有时会想在浏览器中使用.claude下配置的skill,但是来回copy比较复杂。
写了一个油猴脚本,用于将在网页端配置、使用skill。

最终效果是:

  1. 在网页端聊天框中按下Tab键,会打开一个新的输入框

  2. 在新的输入框输入"/"显示已经配置的skill,选择并使用

  3. 在模态框中按下回车,替换斜杠命令,并发送内容


油猴脚本
// ==UserScript==
// @name         AI Input Commander (Modal Version)
// @namespace    http://tampermonkey.net/
// @version      1.2.0
// @description  通过模态框高效调用并解析 Skill 快捷指令,完美支持上下键、Tab 键与多行输入
// @match        https://gemini.google.com/*
// @match        https://chatgpt.com/*
// @match        https://www.kimi.com/*
// @match        https://chat.deepseek.com/*
// @match        https://chat.z.ai/*
// @match        https://www.doubao.com/*
// @grant        GM_setValue
// @grant        GM_getValue
// @grant        GM_registerMenuCommand
// ==/UserScript==

(function() {
    'use strict';

    // --- 1. 数据初始化与清洗 ---
    let rawSkills = GM_getValue("skills", [
        { name: "summary", description: "精简总结内容", content: "请精简总结以下内容:" },
        { name: "code", description: "代码架构审查", content: "请作为架构师审查这段代码的健壮性:" }
    ]);
    let skills = sanitizeSkills(rawSkills);

    function sanitizeSkills(arr) {
        if (!Array.isArray(arr)) return [];
        return arr.map(s => {
            if (s.name) {
                s.name = s.name.replace(/^\/+/, '').trim();
            }
            return s;
        }).filter(s => s.name);
    }

    // --- 2. 状态变量 ---
    let lastActiveElement = null;
    let selectedIndex = -1;
    let filteredSkills = [];

    // 注入通用全局样式
    const css = `
        .cmd-item:hover, .cmd-item.active { background-color: #f0fdf4 !important; }
        .cmd-btn { cursor: pointer; padding: 6px 14px; border: 1px solid #d1d5db; border-radius: 6px; background: #fff; font-size: 13px; transition: all 0.2s; user-select: none; }
        .cmd-btn:hover { background: #f3f4f6; border-color: #9ca3af; }
        .cmd-btn-primary { background: #2563eb !important; color: #fff !important; border-color: #2563eb !important; }
        .cmd-btn-primary:hover { background: #1d4ed8 !important; }
        .cmd-btn-danger { color: #dc2626 !important; border-color: #fca5a5 !important; }
        .cmd-btn-danger:hover { background: #fef2f2 !important; }
        .cmd-input { width: 100%; padding: 8px 12px; border: 1px solid #c8cbd0; border-radius: 6px; outline: none; font-size: 14px; box-sizing: border-box; }
        .cmd-input:focus { border-color: #2563eb; box-shadow: 0 0 0 2px rgba(37,99,235,0.2); }
    `;
    const styleSheet = document.createElement("style");
    styleSheet.innerText = css;
    document.head.appendChild(styleSheet);

    // --- 3. 功能:创建并显示【配置管理】独立模态框 ---
    function showConfigModal() {
        if (document.getElementById('cmd-config-overlay')) return;

        const configOverlay = document.createElement('div');
        configOverlay.id = 'cmd-config-overlay';
        Object.assign(configOverlay.style, {
            position: 'fixed', top: '0', left: '0', width: '100vw', height: '100vh',
            backgroundColor: 'rgba(0,0,0,0.5)', zIndex: '100005', fontFamily: 'Segoe UI, system-ui, sans-serif',
            overflow: "auto"
        });

        const configModal = document.createElement('div');
        Object.assign(configModal.style, {
            position: 'absolute', top: '8%', left: '50%', transform: 'translateX(-50%)',
            width: '750px', padding: '24px', background: '#ffffff', borderRadius: '12px',
            boxShadow: '0 25px 50px -12px rgba(0,0,0,0.25)', display: 'flex', flexDirection: 'column', gap: '16px', boxSizing: 'border-box'
        });

        // 3.1 头部栏
        const header = document.createElement('div');
        Object.assign(header.style, { display: 'flex', justifyContent: 'space-between', alignItems: 'center', borderBottom: '1px solid #e5e7eb', paddingBottom: '12px' });
        const title = document.createElement('div');
        title.textContent = '📊 Skill 配置管理中心';
        Object.assign(title.style, { fontWeight: 'bold', fontSize: '16px', color: '#1f2937' });
        const btnCloseX = document.createElement('button');
        btnCloseX.className = 'cmd-btn';
        btnCloseX.textContent = '✕';
        Object.assign(btnCloseX.style, { border: 'none', fontSize: '16px', padding: '0 4px' });
        header.appendChild(title);
        header.appendChild(btnCloseX);

        // 3.2 快捷添加区域【🌟此处已完美修复样式语法错误】
        const addSection = document.createElement('div');
        Object.assign(addSection.style, {
            display: 'flex',
            flexDirection: 'column',
            gap: '10px',
            background: '#f9fafb',
            padding: '14px',
            borderRadius: '8px',
            border: '1px solid #e5e7eb'
        });
        const addTitle = document.createElement('div');
        addTitle.textContent = '➕ 快捷单条追加';
        Object.assign(addTitle.style, { fontSize: '13px', fontWeight: '600', color: '#4b5563' });

        const row1 = document.createElement('div');
        Object.assign(row1.style, { display: 'flex', gap: '10px' });
        const inputName = document.createElement('input');
        inputName.type = 'text'; inputName.className = 'cmd-input'; inputName.placeholder = '命令名称 (如 summary)';
        const inputDesc = document.createElement('input');
        inputDesc.type = 'text'; inputDesc.className = 'cmd-input'; inputDesc.placeholder = '简短描述 (如 总结长文本)';
        row1.appendChild(inputName); row1.appendChild(inputDesc);

        const row2 = document.createElement('div');
        const textContent = document.createElement('textarea');
        textContent.className = 'cmd-input'; textContent.rows = 4; textContent.placeholder = '在此输入完整的 Skill 长 Prompt 内容...';
        Object.assign(textContent.style, { resize: 'vertical', fontFamily: 'inherit' });
        row2.appendChild(textContent);

        const row3 = document.createElement('div');
        Object.assign(row3.style, { display: 'flex', justifyContent: 'flex-end' });
        const btnQuickAdd = document.createElement('button');
        btnQuickAdd.className = 'cmd-btn cmd-btn-primary'; btnQuickAdd.textContent = '确认添加';
        Object.assign(btnQuickAdd.style, { minWidth: '100px' });
        row3.appendChild(btnQuickAdd);

        addSection.appendChild(addTitle); addSection.appendChild(row1); addSection.appendChild(row2); addSection.appendChild(row3);

        // 3.3 JSON 区域
        const jsonSection = document.createElement('div');
        Object.assign(jsonSection.style, { display: 'flex', flexDirection: 'column', gap: '6px' });
        const jsonTitle = document.createElement('div');
        jsonTitle.textContent = '📝 JSON 数据流';
        Object.assign(jsonTitle.style, { fontSize: '13px', fontWeight: '600', color: '#4b5563' });
        const jsonTextarea = document.createElement('textarea');
        jsonTextarea.className = 'cmd-input'; jsonTextarea.rows = 6; jsonTextarea.placeholder = '在此粘贴 Skill 数组 JSON...';
        Object.assign(jsonTextarea.style, { fontFamily: 'monospace', fontSize: '13px', lineHeight: '1.4', resize: 'vertical' });
        jsonSection.appendChild(jsonTitle); jsonSection.appendChild(jsonTextarea);

        // 3.4 底部操作栏
        const footer = document.createElement('div');
        Object.assign(footer.style, { display: 'flex', justifyContent: 'space-between', alignItems: 'center', borderTop: '1px solid #e5e7eb', paddingTop: '14px' });
        const tipsSpan = document.createElement('span');
        Object.assign(tipsSpan.style, { fontSize: '13px', color: '#4b5563', fontWeight: '500' });

        const btnGroup = document.createElement('div');
        Object.assign(btnGroup.style, { display: 'flex', gap: '8px' });
        const btnClear = document.createElement('button'); btnClear.className = 'cmd-btn cmd-btn-danger'; btnClear.textContent = '清空全部';
        const btnMerge = document.createElement('button'); btnMerge.className = 'cmd-btn'; btnMerge.textContent = '📥 追加合并';
        const btnCover = document.createElement('button'); btnCover.className = 'cmd-btn cmd-btn-primary'; btnCover.textContent = '💾 覆盖保存';
        btnGroup.appendChild(btnClear); btnGroup.appendChild(btnMerge); btnGroup.appendChild(btnCover);
        footer.appendChild(tipsSpan); footer.appendChild(btnGroup);

        // 组装面板
        configModal.appendChild(header); configModal.appendChild(addSection); configModal.appendChild(jsonSection); configModal.appendChild(footer);
        configOverlay.appendChild(configModal); document.body.appendChild(configOverlay);

        // 初始化填入数据
        jsonTextarea.value = JSON.stringify(skills, null, 2);
        tipsSpan.textContent = `当前共有 ${skills.length} 个 Skill`;

        function syncAndSave() {
            skills = sanitizeSkills(skills);
            GM_setValue("skills", skills);
            jsonTextarea.value = JSON.stringify(skills, null, 2);
            tipsSpan.textContent = `当前共有 ${skills.length} 个 Skill`;
        }

        btnQuickAdd.onclick = () => {
            let n = inputName.value.trim(); const d = inputDesc.value.trim(); const c = textContent.value.trim();
            if (!n || !c) { alert("命令和内容不能为空!"); return; }
            n = n.replace(/^\/+/, '').trim();
            skills = skills.filter(s => s.name.toLowerCase() !== n.toLowerCase());
            skills.push({ name: n, description: d, content: c });
            syncAndSave();
            inputName.value = ''; inputDesc.value = ''; textContent.value = '';
        };

        btnCover.onclick = () => {
            try {
                const parsed = JSON.parse(jsonTextarea.value);
                if (Array.isArray(parsed)) { skills = sanitizeSkills(parsed); syncAndSave(); alert("💾 覆盖保存成功!"); } else { alert("格式错误:必须是一个数组 []"); }
            } catch(err) { alert("JSON 语法解析错误,请检查格式!"); }
        };

        btnMerge.onclick = () => {
            try {
                const parsed = JSON.parse(jsonTextarea.value);
                if (Array.isArray(parsed)) {
                    const cleanedNewSkills = sanitizeSkills(parsed);
                    cleanedNewSkills.forEach(newSkill => {
                        skills = skills.filter(s => s.name.toLowerCase() !== newSkill.name.toLowerCase());
                        skills.push(newSkill);
                    });
                    syncAndSave(); alert(`📥 合并成功!`);
                } else { alert("格式错误:必须是一个数组 []"); }
            } catch(err) { alert("JSON 语法解析错误,请检查格式!"); }
        };

        btnClear.onclick = () => { if (confirm("确定要清空本地所有快捷命令吗?")) { skills = []; syncAndSave(); } };

        btnCloseX.onclick = () => document.body.removeChild(configOverlay);
        configOverlay.onclick = (e) => { if(e.target === configOverlay) document.body.removeChild(configOverlay); };
    }

    // --- 4. 功能:创建并显示【主命令输入框】模态框 ---
    function showCommander() {
        if (document.getElementById('cmd-overlay')) return;

        lastActiveElement = document.activeElement;
        selectedIndex = -1;
        filteredSkills = [];

        const overlay = document.createElement('div');
        overlay.id = 'cmd-overlay';
        Object.assign(overlay.style, {
            position: 'fixed', top: '0', left: '0', width: '100vw', height: '100vh',
            backgroundColor: 'rgba(0,0,0,0.4)', zIndex: '100000', fontFamily: 'Segoe UI, system-ui, sans-serif'
        });

        const modal = document.createElement('div');
        Object.assign(modal.style, {
            position: 'absolute', top: '15%', left: '50%', transform: 'translateX(-50%)',
            width: '650px', padding: '20px', background: '#ffffff', borderRadius: '12px',
            boxShadow: '0 20px 25px -5px rgba(0,0,0,0.1), 0 10px 10px -5px rgba(0,0,0,0.04)',
            display: 'flex', flexDirection: 'column', gap: '12px', boxSizing: 'border-box'
        });

        const textarea = document.createElement('textarea');
        textarea.placeholder = "输入 / 唤出指令,上下键/Tab/鼠标选择补全,无高亮时 Enter 解析发送...";
        textarea.rows = 4;
        Object.assign(textarea.style, {
            width: '100%', padding: '12px', fontSize: '15px', border: '1px solid #c8cbd0',
            borderRadius: '6px', resize: 'vertical', outline: 'none', boxSizing: 'border-box',
            lineHeight: '1.5', color: '#1f2937'
        });

        const listContainer = document.createElement('div');
        Object.assign(listContainer.style, {
            maxHeight: '180px', overflowY: 'auto', border: '1px solid #e5e7eb',
            borderRadius: '6px', display: 'none', backgroundColor: '#ffffff'
        });

        const actionBar = document.createElement('div');
        Object.assign(actionBar.style, { display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginTop: '4px' });

        const versionSpan = document.createElement('span');
        versionSpan.textContent = 'AI Commander v2.1';
        Object.assign(versionSpan.style, { fontSize: '12px', color: '#9ca3af' });

        const mainBtnGroup = document.createElement('div');
        Object.assign(mainBtnGroup.style, { display: 'flex', gap: '8px' });

        const btnConfig = document.createElement('button'); btnConfig.className = 'cmd-btn'; btnConfig.textContent = '⚙️ 配置管理';
        const btnClose = document.createElement('button'); btnClose.className = 'cmd-btn'; btnClose.textContent = '关闭 (Esc)';
        mainBtnGroup.appendChild(btnConfig); mainBtnGroup.appendChild(btnClose);

        actionBar.appendChild(versionSpan);
        actionBar.appendChild(mainBtnGroup);

        function renderList() {
            const text = textarea.value.trim();
            if (text.startsWith('/')) {
                const hasSpaceAfterCmd = text.substring(1).includes(' ');
                if (hasSpaceAfterCmd) {
                    filteredSkills = [];
                } else {
                    const searchKey = text.replace(/^\/+/, '').toLowerCase();
                    filteredSkills = skills.filter(s => s.name.toLowerCase().startsWith(searchKey));
                }
            } else {
                filteredSkills = [];
            }

            while (listContainer.firstChild) {
                listContainer.removeChild(listContainer.firstChild);
            }

            if (filteredSkills.length === 0) {
                listContainer.style.display = 'none';
                selectedIndex = -1;
                return;
            }

            listContainer.style.display = 'block';

            filteredSkills.forEach((s, i) => {
                const itemDiv = document.createElement('div');
                itemDiv.id = `cmd-item-${i}`;
                itemDiv.setAttribute('data-index', i);
                itemDiv.className = `cmd-item ${i === selectedIndex ? 'active' : ''}`;

                Object.assign(itemDiv.style, {
                    padding: '10px 14px', cursor: 'pointer', display: 'flex',
                    justifyContent: 'space-between', alignItems: 'center',
                    borderBottom: '1px solid #f3f4f6', background: '#fff'
                });

                const nameSpan = document.createElement('span');
                nameSpan.textContent = `/${s.name}`;
                Object.assign(nameSpan.style, { color: '#2563eb', fontWeight: '600', fontSize: '14px', display: 'inline', flexShrink: 0 });

                const descSpan = document.createElement('span');
                descSpan.textContent = s.description || '';
                descSpan.title = s.description || '';
                Object.assign(descSpan.style, { color: '#6b7280', fontSize: '13px', fontWeight: 'normal', display: 'inline', marginLeft: '20px', whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' });

                itemDiv.appendChild(nameSpan); itemDiv.appendChild(descSpan);
                listContainer.appendChild(itemDiv);
            });

            if (selectedIndex >= 0) {
                const activeItem = document.getElementById(`cmd-item-${selectedIndex}`);
                if (activeItem) activeItem.scrollIntoView({ block: 'nearest' });
            }
        }

        textarea.oninput = () => { selectedIndex = -1; renderList(); };

        textarea.onkeydown = (e) => {
            if (e.key === 'ArrowDown' && filteredSkills.length > 0) {
                e.preventDefault(); selectedIndex = (selectedIndex + 1) % filteredSkills.length; renderList(); return;
            }
            if (e.key === 'ArrowUp' && filteredSkills.length > 0) {
                e.preventDefault(); selectedIndex = (selectedIndex - 1 + filteredSkills.length) % filteredSkills.length; renderList(); return;
            }
            if (e.key === 'Tab') {
                e.preventDefault();
                if (filteredSkills.length > 0) {
                    if (selectedIndex === -1) selectedIndex = 0;
                    textarea.value = "/" + filteredSkills[selectedIndex].name + " "; renderList();
                }
                return;
            }
            if (e.key === 'Enter') {
                if (e.shiftKey) return;
                e.preventDefault();
                if (selectedIndex >= 0 && selectedIndex < filteredSkills.length) {
                    textarea.value = "/" + filteredSkills[selectedIndex].name + " "; selectedIndex = -1; renderList();
                } else {
                    const finalContent = parseContent(textarea.value); closeAndFill(finalContent, true);
                }
            }
            if (e.key === 'Escape') { e.preventDefault(); closeAndFill(textarea.value, false); }
        };

        listContainer.onclick = (e) => {
            const item = e.target.closest('.cmd-item');
            if (item) {
                const idx = parseInt(item.getAttribute('data-index'));
                textarea.value = "/" + filteredSkills[idx].name + " "; textarea.focus(); selectedIndex = -1; renderList();
            }
        };

        btnClose.onclick = () => closeAndFill(textarea.value, false);
        btnConfig.onclick = () => showConfigModal();

        modal.appendChild(textarea); modal.appendChild(listContainer); modal.appendChild(actionBar);
        overlay.appendChild(modal); document.body.appendChild(overlay);

        textarea.focus();

        function closeAndFill(content, shouldSend) {
            document.body.removeChild(overlay); fillOriginalInput(content, shouldSend);
        }
    }

    function parseContent(text) {
        const targetText = text.trim();
        if (!targetText.startsWith('/')) return text;

        const matchCmd = targetText.match(/^\/+([^\s]+)/);
        if (matchCmd) {
            const parsedName = matchCmd[1].toLowerCase();
            const foundSkill = skills.find(s => s.name.toLowerCase() === parsedName);
            if (foundSkill) {
                const userContent = targetText.substring(matchCmd[0].length).trim();
                return (
                    `<skill name="${foundSkill.name}">${foundSkill.content}</skill>\n` +
                    `<command-name>/${foundSkill.name}</command-name>\n<command-args>${userContent}</command-args>`
                );
            }
        }
        return text;
    }

    function fillOriginalInput(content, shouldSend) {
        if (!lastActiveElement) return;
        lastActiveElement.focus();

        document.execCommand('selectAll', false, null);
        document.execCommand('insertText', false, content);

        const inputEvent = new Event('input', { bubbles: true, cancelable: true });
        const changeEvent = new Event('change', { bubbles: true, cancelable: true });
        lastActiveElement.dispatchEvent(inputEvent);
        lastActiveElement.dispatchEvent(changeEvent);

        if (shouldSend) {
            setTimeout(() => {
                const selectors = [
                    'button[aria-label*="Send"]', 'button[aria-label*="发送"]',
                    '[data-testid="send-button"]', 'button[data-testid="fruitjuice-send-button"]',
                    'button[class*="send"]', 'button:has(svg path[d^="M2"])'
                ];
                let sendBtn = null;
                for (let selector of selectors) {
                    sendBtn = document.querySelector(selector);
                    if (sendBtn && !sendBtn.disabled) break;
                }
                if (sendBtn) {
                    sendBtn.click();
                } else {
                    const enterEvent = new KeyboardEvent('keydown', {
                        key: 'Enter', code: 'Enter', keyCode: 13, which: 13, bubbles: true, cancelable: true
                    });
                    lastActiveElement.dispatchEvent(enterEvent);
                }
            }, 150);
        }
    }

    document.addEventListener('keydown', (e) => {
        if (e.key === 'Tab' && !document.getElementById('cmd-overlay')) {
            const active = document.activeElement;
            if (active && (active.tagName === 'TEXTAREA' || active.isContentEditable)) {
                e.preventDefault(); showCommander();
            }
        }
    }, true);
})();
  • 目前测试ok的网页:
  • 由于不支持直接访问本地目录,附上一个导出.claude/skills中配置的skill的js脚本
    • 使用示例:任意目录执行 node .\claude-skill-to-json.js > 要保存的位置.json
    • 然后将json通过配置管理导入到网页端
claude-skill-to-json.js文件内容

// 使用示例: node .\claude-skill-to-json.js > 要保存的位置.json
const fs = require(‘fs’);
const path = require(‘path’);
const os = require(‘os’); // 引入 os 模块

/**

  • 修改后的配置:始终指向用户主目录下的 .claude/skills
  • 例如:/Users/yourname/.claude/skills 或 C:\Users\yourname.claude\skills
    */
    const SKILLS_DIR = path.join(os.homedir(), ‘.claude’, ‘skills’);

/**

  • 简单的 YAML Frontmatter 解析器
    /
    function parseSkillFile(filePath) {
    try {
    const content = fs.readFileSync(filePath, ‘utf8’);
    const regex = /^—\r?\n([\s\S]
    ?)\r?\n—\r?\n([\s\S]*)$/;
    const match = content.match(regex);

    if (!match) return null;

    const yamlSection = match[1];
    const bodyContent = match[2].trim();

    const metadata = {};
    yamlSection.split(‘\n’).forEach(line => {
    const [key, …valueParts] = line.split(‘:’);
    if (key && valueParts.length) {
    metadata[key.trim()] = valueParts.join(‘:’).trim();
    }
    });

    return {
    name: metadata.name || ‘unknown’,
    description: metadata.description || ‘’,
    content: bodyContent
    };
    } catch (e) {
    return null;
    }
    }

function main() {
if (!fs.existsSync(SKILLS_DIR)) {
console.error(Error: 技能目录不存在于用户目录: ${SKILLS_DIR});
process.exit(1);
}

const results = ;
const folders = fs.readdirSync(SKILLS_DIR);

folders.forEach(folder => {
const folderPath = path.join(SKILLS_DIR, folder);

if (fs.statSync(folderPath).isDirectory()) {
  const skillFilePath = path.join(folderPath, 'SKILL.md');

  if (fs.existsSync(skillFilePath)) {
    const skillData = parseSkillFile(skillFilePath);
    if (skillData) {
      results.push(skillData);
    }
  }
}

});

// 输出最终 JSON
console.log(JSON.stringify(results, null, 2));
}

main();

21 个赞

还挺有用的,agent 里用习惯了 skill ,网页也想用。没办法只能手动复制,很繁琐。这个插件可以:+1:

1 个赞

不错不错,我记得之前chatgpt和claude的web端都支持过skills,然后不知什么时候又没了,真的气人!

太酷了吧,原来还能这么用,收藏收藏,谢谢大佬