我最早被OpenCode圈粉是因为它把“Skills”这个概念做得足够接地气——不用改模型、不用重训AI只要往技能目录里塞一个带描述的文件和一段脚本AI就突然会干一件新事。前阵子我把浏览器里两千多条书签翻出来处理顺手就做成了一个完整的“网页书签”Skills。这篇就手把手记录整个实现过程从环境搭建、目录设计、脚本编写到调试避坑按我的实际过程走一遍任何人照着抄都能整出来。1. 先搞清楚OpenCode里“Skills”到底是个什么东西1.1 把Skills理解成“给AI塞说明书工具箱”以前用各种AI编程工具的时候最烦的一点是你得把需求用自然语言写得很详细模型才能猜到你想要什么。比如你跟它说“帮我整理一下书签”它大概率会回你一段Python代码然后让你自己跑。但有了Skills之后情况完全变了。Skills本质上是两层东西的组合一层是给模型看的说明书SKILL.md告诉模型“在什么场景下调用我、怎么调用、参数怎么传”另一层是真正干活的程序脚本或可执行文件由模型在需要的时候去运行。你可以理解为你给AI配了一个“专项顾问”——模型负责判断何时需要这个顾问出场顾问负责把具体事情做掉。OpenCode在这方面的优势是开放。对比Claude Code那种封闭式Skills市场OpenCode直接把技能目录放在本地你用普通编辑器就能写、能改、能调试没有任何平台锁定的问题。说白了它是把“技能生态”这个概念去中心化了。1.2 我为什么选“网页书签”当第一个练手项目选书签做第一个Skills是因为它踩中了几个关键点第一数据源简单——浏览器书签就是一份HTML文件不需要对接数据库、不需要写服务端本地文件就能搞定第二需求明确——“找一条收藏过的网址”“统计我平时都在收藏哪些站”“看看有没有重复书签”这些都是真实的高频诉求第三非常容易验证效果跑一条命令出结果不像搞个web服务还要启动半天。另外一个很现实的原因书签文件结构看起来简单但真要解析好用坑不少。比如文件夹嵌套、中文编码、空文件夹、重复链接、快捷键伪书签。把这套处理完你对“如何把现实世界的脏数据喂给AI”就很有心得了。做通一个Skills后续再做别的技能基本就是换汤不换药。1.3 这个Skills要承担的任务清单我给自己定的v1.0功能清单主打“读和查”没有涉及“写”后面会说为什么解析Chrome/Edge导出的书签HTML输出完整的书签树按关键词搜索书签名称和URL返回命中的完整路径按文件夹分类查看模拟浏览器里的书签栏层级统计域名分布看看自己收藏最多的是哪些网站检测重复书签相同URL出现多次这套功能覆盖了日常80%的书签管理需求。我不做增删改是因为改文件的风险很高一个误操作把用户整棵书签树搞坏了AI再厉害都没法救回来。查询是零风险的先把零风险的部分做好再考虑扩展写操作。2. 环境准备与目录规划2.1 安装OpenCode三分钟起步安装这一步不用太纠结OpenCode提供了一条安装命令。我从官网复制了安装脚本在终端跑一遍整个过程基本无感装完验证一下版本号就行了。# macOS / Linux 安装 curl -fsSL https://opencode.ai/install | bash # 装完检查版本 opencode --versionWindows环境我建议优先用PowerShell跑官方安装脚本实测下来比WSL省事很多。这里插一句个人的体会Windows上跑OpenCode终端选择挺重要。我试过Windows Terminal自带PowerShell、cmder、Git Bash最终固定在PowerShell 7主要因为它的字体渲染和复制粘贴都顺滑fzf那种交互式搜索不会乱码。如果你是Windows用户强烈建议别用老旧的cmd。2.2 Skills目录在哪里建、怎么被识别OpenCode的技能目录默认路径是~/.opencode/skills/Windows是C:\Users\你的用户名\.opencode\skills\。这个目录下每一个子文件夹就是一个技能。我第一次找这个目录的时候没概念以为要建在项目里结果技能一直没生效。它必须放在用户级目录才能跨项目复用。打开终端先把目录建起来mkdir -p ~/.opencode/skills/web-bookmarks每个技能文件夹里必须有一个SKILL.md这是核心。模型读说明书就是靠这个文件。你写的技能要生效说明书就得符合OpenCode约定的格式。我后面会专门讲怎么写这份说明书现在先把目录结构准备好。2.3 书签数据从哪里来Chrome和Edge导出书签的路径有点反直觉默认不是“导出”而是“打开书签管理器 → 整理 → 导出书签”。导出后得到的是一个bookmarks_xx月xx日.html文件。这个文件本身是标准格式开头的DOCTYPE写的很清楚!DOCTYPE NETSCAPE-Bookmark-file-1。Safari和Firefox导出格式大同小异都能用同一套解析逻辑。导出的书签HTML顶层长这样!DOCTYPE NETSCAPE-Bookmark-file-1 META HTTP-EQUIVContent-Type CONTENTtext/html; charsetUTF-8 TITLEBookmarks/TITLE H1Bookmarks/H1 DLp DTH3 ADD_DATE1586875341 PERSONAL_TOOLBAR_FOLDERtrue书签栏/H3 DLp DTA HREFhttps://github.com/ ADD_DATE1612451803GitHub/A /DLp /DLp这就是我们要喂给解析器的原始数据。注意层级嵌套是用DL来包裹的一个DL的结束代表一个文件夹内容的结束。把书签文件放到哪呢我建议就放在技能目录下建一个data/子文件夹方便脚本固定读取。当然更好的做法是脚本支持传入路径但你作为调用方要提供默认值避免每次都要带参数。3. 网页书签Skills的核心设计3.1 先给AI划边界这个技能擅长什么写SKILL.md之前最关键的一步不是写代码而是界定技能边界。一个技能如果描述得太宽泛AI就会在错误时机调用它。比如你把“整理书签”写进去AI可能在你问“我该不该把某网站存下来”的时候就开始跑技能而不是先跟你对话。所以我只写明“查询”类能力一句话总结然后列出具体触发场景。边界定了之后脚本的输入输出也要明确。我给脚本定义的输入是动作action、关键词keyword、文件夹folder。输出是JSON格式的文本。为什么用JSON因为AI读结构化文本比读散文快得多也不用我做额外的文本解析。而且JSON很容易人类阅读出了问题一眼就能看到。3.2 SKILL.md说明书怎么写这是技能的灵魂文件。我把完整的SKILL.md贴出来照着这套写结构基本不会错--- name: web-bookmarks description: 查询浏览器导出的书签文件。支持按关键词搜索书签、按文件夹查看分类、统计域名分布、检测重复书签。当用户提到书签收藏夹我收藏过的网站等话题时使用该技能。 --- # 网页书签工具 ## 能做什么 - 按关键词搜索书签搜索名称或URL包含关键词的书签返回完整路径 - 按文件夹查看列出指定文件夹下的所有书签和子文件夹 - 统计域名分布按域名聚合书签数量输出排行榜 - 检测重复查找URL完全相同的重复书签 ## 用法 运行以下脚本传入不同参数 bash node ~/.opencode/skills/web-bookmarks/index.js --action search --keyword github node ~/.opencode/skills/web-bookmarks/index.js --action folder --folder 书签栏 node ~/.opencode/skills/web-bookmarks/index.js --action stats node ~/.opencode/skills/web-bookmarks/index.js --action duplicates说明书签数据默认读取同目录下 data/bookmarks.html如无则提示用户先导出书签输出为JSONresult字段为结果数组搜索时如无结果error字段为 no_result这里有个很关键的细节SKILL.md 里的 description 字段写的是“触发条件”和“能力范围”。我用“当用户提到…时使用该技能”这种语句。OpenCode的模型在看到和你对话的内容时会根据这个描述决定是否加载技能。写得太细模型会乱写得太粗模型该用的时候不用。经验是写清触发词和禁用场景两边夹住。 ### 3.3 脚本解析器的设计思路 干活的核心是 index.js。我选Node.js而不是Python主要考虑到OpenCode生态本身是Node生态技能目录里带个小脚本没必要再拉一个Python运行时而且Node在Windows上解析文件路径更省心。如果你习惯Python逻辑一样翻译过去就行。 解析器要处理的HTML格式有这么几个要点 - 文件夹标记是 DTH3 ...名称/H3内容和 DL 配对 - 书签标记是 DTA HREFurl ADD_DATE...名称/A - 书签名称里允许有HTML实体比如 amp;你得反转义 - 文件夹嵌套层数不限需要靠栈来维护层级 我第一版用正则硬提取结果遇到嵌套就崩了。后来老实换成逐行扫描加栈。读HTML文件的时候还要注意编码。虽然导出文件声明了 charsetUTF-8但实测Windows导出的文件在部分环境下是GBK编码头两行读出来乱码。我做的兜底是先用UTF-8读读出来含“锟斤拷”之类的乱码再换GBK重读。 ### 3.4 为什么查询类技能要“只读不写” 我还专门在SKILL.md里加了一条硬性约束“本技能只做查询不修改书签文件。”这不是怕麻烦是真的为AI好。AI在一个会话里只能通过脚本拿到结果如果脚本有“删除书签”这种高危操作模型一旦调用错误参数用户的整棵书签树就没了。而查询类操作再怎么错最多是返回空结果不会造成数据损坏。 如果你后续想扩展“添加书签”功能我建议单独做一个 add-bookmark 技能把读写拆开并且在SKILL.md里写清楚修改操作的幂等性重复添加同一URL自动跳过避免积累重复数据。 ## 4. 编码实现与调试过程 ### 4.1 完整解析脚本可直接抄 下面是我整理后的 index.js可以整个复制使用。核心逻辑分三块解析书签HTML、按动作分发、格式化输出。我加了比较详细的注释 javascript #!/usr/bin/env node const fs require(fs); const path require(path); // ---------- 工具函数 ---------- function decodeHtml(str ) { return str .replace(/amp;/g, ) .replace(/lt;/g, ) .replace(/gt;/g, ) .replace(/quot;/g, ) .replace(/#39;/g, ) .replace(/nbsp;/g, ); } // 解析书签HTML返回书签树 function parseBookmarks(html) { const root { name: 根目录, type: folder, children: [] }; const stack [root]; const lines html.split(\n); for (let i 0; i lines.length; i) { const line lines[i].trim(); // 文件夹开始 if (line.includes(DTH3)) { const nameMatch line.match(/H3[^]*(.*?)\/H3/); const folder { name: decodeHtml(nameMatch ? nameMatch[1] : 未命名文件夹), type: folder, children: [] }; stack[stack.length - 1].children.push(folder); stack.push(folder); } // 书签项 else if (line.includes(DTA)) { const hrefMatch line.match(/HREF([^]*)/); if (!hrefMatch) continue; const nameMatch line.match(/A[^]*(.*?)\/A/); const addDateMatch line.match(/ADD_DATE([^]*)/); stack[stack.length - 1].children.push({ name: decodeHtml(nameMatch ? nameMatch[1] : hrefMatch[1]), url: hrefMatch[1], addDate: addDateMatch ? Number(addDateMatch[1]) : null, type: bookmark }); } // 文件夹结束 else if (line.includes(/DL)) { if (stack.length 1) stack.pop(); } } return root; } // ---------- 查询功能 ---------- // 打平书签树附带完整路径 function flatten(node, pathStr [], result []) { const currentPath [...pathStr, node.name]; if (node.type folder) { for (const child of node.children) { flatten(child, currentPath, result); } } else { result.push({ ...node, path: currentPath.join( ) }); } return result; } // 搜索书签名称或URL包含关键词 function search(bookmarks, keyword) { const kw keyword.toLowerCase(); return flatten(bookmarks).filter(item item.name.toLowerCase().includes(kw) || item.url.toLowerCase().includes(kw) ); } // 按文件夹筛选 function filterByFolder(bookmarks, folderName) { function findFolder(node, name) { if (node.type folder node.name name) return node; if (node.type folder) { for (const child of node.children) { const found findFolder(child, name); if (found) return found; } } return null; } const target findFolder(bookmarks, folderName); if (!target) return []; return flatten(target).filter(item item.type bookmark); } // 统计域名 function stats(bookmarks) { const flat flatten(bookmarks); const map {}; for (const item of flat) { try { const host new URL(item.url).hostname; map[host] (map[host] || 0) 1; } catch(e) { map[[无效URL]] (map[[无效URL]] || 0) 1; } } return Object.entries(map) .sort((a, b) b[1] - a[1]) .map(([domain, count]) ({ domain, count })); } // 查找重复书签 function duplicates(bookmarks) { const flat flatten(bookmarks); const seen {}; const dups []; for (const item of flat) { if (seen[item.url]) { dups.push({ url: item.url, name: item.name, path: item.path }); } else { seen[item.url] true; } } return dups; } // ---------- 主逻辑 ---------- function main() { // 解析命令行参数 const args process.argv.slice(2); const getArg (key) { const idx args.indexOf(-- key); return idx ! -1 args[idx 1] ? args[idx 1] : null; }; const action getArg(action) || search; const keyword getArg(keyword) || ; const folder getArg(folder) || ; // 书签文件默认在 data/bookmarks.html const dataFile path.join(__dirname, data, bookmarks.html); if (!fs.existsSync(dataFile)) { console.log(JSON.stringify({ error: no_bookmark_file, message: 请先在 data/ 目录放浏览器导出的书签HTML文件 })); return; } let html; try { html fs.readFileSync(dataFile, utf8); } catch (e) { console.log(JSON.stringify({ error: read_failed, message: String(e) })); return; } const tree parseBookmarks(html); let result []; switch (action) { case search: result search(tree, keyword || ); break; case folder: result filterByFolder(tree, folder || 书签栏); break; case stats: result stats(tree); break; case duplicates: result duplicates(tree); break; default: console.log(JSON.stringify({ error: unknown_action, message: action })); return; } console.log(JSON.stringify({ action, count: result.length, result: result.slice(0, 50) // 防止输出爆炸最多返回50条 })); } main();注意输出部分我做了slice(0, 50)限制。这个限制是必需的——我遇到过书签里3000条都包含“cn”关键词的情况如果脚本直接全量输出AI的上下文窗口会被刷爆后面的对话质量会明显下降。给个不超过50条的结果AI可以基于这些做二次筛选或让你换更精确的关键词。4.2 常见坑之一文件名和路径的老毛病我第一次调试就翻车在路径上。技能目录下的data/bookmarks.html是我手动放的脚本当时写的是./data/bookmarks.html看起来没问题是吧但活得看OpenCode当前工作目录。OpenCode如果在别的项目目录启动脚本的./data/...就会指向项目目录下的 data导致找不到书签文件。所以我改成用__dirname拼路径__dirname永远指向脚本所在位置这个才稳。Windows上还有另一个坑书签文件如果是从Chrome导出的文件名里可能带中文或者空格脚本调用时如果参数没加引号会被拆成两个参数。我自己的做法是统一把书签改名为bookmarks.html再放进 data 目录名字固定了所有调用方都不容易传错。4.3 调试这门Skills的正确姿势OpenCode里调试Skills不要直接在对话里反复试很费token。我推荐你先把脚本单独拉到终端里跑通node ~/.opencode/skills/web-bookmarks/index.js --action stats node ~/.opencode/skills/web-bookmarks/index.js --action search --keyword 博客脚本输出正常之后再考虑让AI调用。OpenCode有个好处是技能调用时会打印执行的命令和输出你可以在界面上看到AI实际跑的命令是什么。如果AI传错了参数你在界面上直接就能发现问题然后改SKILL.md的描述。我还给SKILL.md里加了一条“调试提示”要求AI在调用脚本后用一句话告诉用户查到了多少条结果。这个小改动让整个使用体验提升一大截因为AI不再是干巴巴丢JSON给你而是会先给结论再展开结果。4.4 注册到OpenCode让AI认识这个技能OpenCode对Skills目录下的技能是自动发现的你新建了目录和SKILL.md之后重启OpenCode应该就能生效。但我不放心还额外检查了一下——你在OpenCode对话里问一句“你有网页书签技能吗”看它回不回答。如果它回答的跟技能描述吻合那就是加载成功了。这里有一个版本差异要提醒不同OpenCode版本对Skills的加载方式略有不同。有些版本要求你在设置里明确开启“用户技能目录”有些版本会自动扫描。如果你发现技能没生效去配置里找找类似 “Skills” 开关的选项打开它重启应用就好。官方文档里的指引比任何第三方教程都准遇到怪问题优先查官方文档。5. 常见问题与排查技巧实录5.1 我踩过的几个典型问题把我在开发和使用这个Skills过程中遇到的问题整理成速查表方便你对症下药问题现象可能原因解决方法脚本输出乱码书签文件编码不是UTF-8用其他编码重读或者先转成UTF-8再放进来搜索“github”没有结果关键词太小写不匹配或者书签确实没有该关键词先确认书签文件不为空再登录看脚本搜原始数据AI调用技能时报权限错误脚本没有执行权限chmod x index.js技能没被AI识别SKILL.md 里的 description 太宽泛或目录放错检查目录位置重启OpenCode解析后文件夹嵌套错乱HTML里存在没有H3的DL调整解析逻辑遇到DL但不带H3时不能盲目push栈输出结果太多AI被刷爆结果超50条仍全量返回增加限制逻辑先返回前20条并提示总数Windows路径反斜杠导致URL错误书签里的URL被拼接路径从书签HTML里直接提取HREF不要用路径拼接的方式转URL第一版解析脚本遇到“文件夹嵌套错乱”踩得最深。Chrome导出文件偶尔会多出一个不配对DL如果我的解析逻辑见到/DL就pop多出的那个会把根目录都弹没了。后来我加上“栈至少要保留根目录”的保护并且对空DL单独处理才算稳定。5.2 二手经验如何让AI“用对”这个技能Skills做得再好如果模型在错误的时机调用它体验一样崩溃。所以SKILL.md的description要认真打磨。我前前后后改了三版第一版写的是“书签查询工具”太宽泛第二版加上了触发词“书签、收藏夹、收藏过的网站”第三版又加了反例写明“用户问怎么导出书签时不要调用这个技能”。第三版之后调用准确率明显提高。还有一个经验是不要让技能脚本自己去下结论。比如搜索“react”你让脚本返回原始JSONAI负责解读。脚本做“纯数据搬运工”AI做“分析师”各司其职。这样技能后续要不要换模型脚本都不用改。5.3 扩展方向从查询到自动化工作流当前版本只做了只读查询后续扩展空间其实很大。我个人计划里优先级最高的几个方向增加“旧书签清理”建议功能根据addDate找出超过两年没用的书签列表由AI判断哪些可以删除但最终删除操作还是由用户手动执行集成浏览器原生书签API调Chrome扩展的接口实时读取而不是依赖导出文件这一步需要在权限设计上花更多工夫增加“书签去重自动合并”能力扫描到重复项后建议保留哪个路径AI生成SQL式的修改清单用户确认后再执行团队协作场景把技能里加一个“按项目标签归类”动作方便几个人共享一个书签库其实做到这里这个Skills已经不只是“网页书签”了它本质上是“把浏览器里的个人知识库结构化”。同样的解析逻辑换个数据源就能用到CSV、JSON、甚至是Notion导出的Markdown上。6. 一些心得和最后的建议这个东西做完之后我最大的感触是Skills开发的门槛比你想象的低但质量分水岭都在细节上。编码处理、路径稳定、输出截断、触发词精准这些看起来不起眼的小事加在一起才决定了一个技能是好用还是“能跑”。好用的技能是那种你每天都会自然用到的工具能跑的技能是你过了新鲜劲就再也不想碰的演示品。如果你打算自己也写一个我建议从你手头最烦的那件小事入手。不用急着做复杂功能先把我这几个步骤走完定义能力清单、写清楚SKILL.md、脚本单独调试通、再让AI在实际对话里调用。等这条链路跑顺了后续任何技能都不会再难倒你。对了最后再分享一个小技巧书签文件本身会越来越大我习惯在每个季度导出一次用这个技能跑一下stats看看这几个月都收藏了什么。跑完发现某类链接明显变多就知道自己最近的重心往哪儿偏了。这不只是整理书签更是在回看自己的注意力投向哪里。工具虽小用久了还挺有意思的。