我最早被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-EQUIV="Content-Type" CONTENT="text/html; charset=UTF-8"> <TITLE>Bookmarks</TITLE> <H1>Bookmarks</H1> <DL><p> <DT><H3 ADD_DATE="1586875341" PERSONAL_TOOLBAR_FOLDER="true">书签栏</H3> <DL><p> <DT><A HREF="https://github.com/" ADD_DATE="1612451803">GitHub</A> </DL><p> </DL><p>这就是我们要喂给解析器的原始数据。注意层级嵌套是用<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,如无则提示用户先导出书签
- 输出为JSON,result字段为结果数组
- 搜索时如无结果,error字段为 "no_result"
这里有个很关键的细节:`SKILL.md` 里的 `description` 字段写的是“触发条件”和“能力范围”。我用“当用户提到…时,使用该技能”这种语句。OpenCode的模型在看到和你对话的内容时会根据这个描述决定是否加载技能。写得太细,模型会乱;写得太粗,模型该用的时候不用。经验是写清触发词和禁用场景,两边夹住。 ### 3.3 脚本解析器的设计思路 干活的核心是 `index.js`。我选Node.js而不是Python,主要考虑到OpenCode生态本身是Node生态,技能目录里带个小脚本没必要再拉一个Python运行时,而且Node在Windows上解析文件路径更省心。如果你习惯Python,逻辑一样,翻译过去就行。 解析器要处理的HTML格式有这么几个要点: - 文件夹标记是 `<DT><H3 ...>名称</H3>`,内容和 `<DL>` 配对 - 书签标记是 `<DT><A HREF="url" ADD_DATE="...">名称</A>` - 书签名称里允许有HTML实体(比如 `&`),你得反转义 - 文件夹嵌套层数不限,需要靠栈来维护层级 我第一版用正则硬提取,结果遇到嵌套就崩了。后来老实换成逐行扫描加栈。读HTML文件的时候,还要注意编码。虽然导出文件声明了 `charset=UTF-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(/&/g, '&') .replace(/</g, '<') .replace(/>/g, '>') .replace(/"/g, '"') .replace(/'/g, "'") .replace(/ /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('<DT><H3')) { 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('<DT><A')) { 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”你让脚本返回原始JSON,AI负责解读。脚本做“纯数据搬运工”,AI做“分析师”,各司其职。这样技能后续要不要换模型,脚本都不用改。
5.3 扩展方向:从查询到自动化工作流
当前版本只做了只读查询,后续扩展空间其实很大。我个人计划里优先级最高的几个方向:
- 增加“旧书签清理”建议功能:根据
addDate找出超过两年没用的书签列表,由AI判断哪些可以删除(但最终删除操作还是由用户手动执行) - 集成浏览器原生书签API:调Chrome扩展的接口实时读取而不是依赖导出文件,这一步需要在权限设计上花更多工夫
- 增加“书签去重自动合并”能力:扫描到重复项后建议保留哪个路径,AI生成SQL式的修改清单,用户确认后再执行
- 团队协作场景:把技能里加一个“按项目标签归类”动作,方便几个人共享一个书签库
其实做到这里,这个Skills已经不只是“网页书签”了,它本质上是“把浏览器里的个人知识库结构化”。同样的解析逻辑,换个数据源就能用到CSV、JSON、甚至是Notion导出的Markdown上。
6. 一些心得和最后的建议
这个东西做完之后,我最大的感触是:Skills开发的门槛比你想象的低,但质量分水岭都在细节上。
编码处理、路径稳定、输出截断、触发词精准,这些看起来不起眼的小事加在一起,才决定了一个技能是好用还是“能跑”。好用的技能是那种你每天都会自然用到的工具,能跑的技能是你过了新鲜劲就再也不想碰的演示品。
如果你打算自己也写一个,我建议从你手头最烦的那件小事入手。不用急着做复杂功能,先把我这几个步骤走完:定义能力清单、写清楚SKILL.md、脚本单独调试通、再让AI在实际对话里调用。等这条链路跑顺了,后续任何技能都不会再难倒你。
对了,最后再分享一个小技巧:书签文件本身会越来越大,我习惯在每个季度导出一次,用这个技能跑一下stats,看看这几个月都收藏了什么。跑完发现某类链接明显变多,就知道自己最近的重心往哪儿偏了。这不只是整理书签,更是在回看自己的注意力投向哪里。工具虽小,用久了还挺有意思的。