diff --git a/dist-electron/main/main.js b/dist-electron/main/main.js index d8adec3..2866ea8 100644 --- a/dist-electron/main/main.js +++ b/dist-electron/main/main.js @@ -1,6 +1,6 @@ "use strict"; require("electron"); -require("./main-CDQKuYIF.js"); +require("./main-D-gZxrru.js"); require("electron-squirrel-startup"); require("electron-log"); require("bytenode"); diff --git a/docs/ClawX-Skill-Install-Migration-Plan.md b/docs/ClawX-Skill-Install-Migration-Plan.md new file mode 100644 index 0000000..b7091d5 --- /dev/null +++ b/docs/ClawX-Skill-Install-Migration-Plan.md @@ -0,0 +1,427 @@ +# ClawX Skill 安装能力迁移计划 + +## 1. 结论先行 + +本轮对比后的结论很明确: + +- `ClawX` 已经具备可用的 Skills marketplace 安装闭环,但它当前的运行时安装输入仍然是 `slug + version`,不是 GitHub skill 链接。 +- `zn-ai` 已经复制了 Skills 页面、Host API 路由、`ClawHubService` 外壳和预装 skill 逻辑,但运行时安装链路还没有真正闭环。 +- `zn-ai` 当前最关键的缺口不是 UI,而是“运行时执行器”和“GitHub skill 目录安装能力”。 +- 如果目标是“用户在对话里贴一个 GitHub skill 链接,应用可以把整目录安装到 `~/.openclaw/skills` 并启用”,那么 `ClawX` 和 `zn-ai` 目前都还没有原生支持这条链路。 + +本轮已经完成一项落地验证: + +- 已把 `MiniMax-AI/skills` 仓库下的 `skills/minimax-xlsx` 完整目录安装到 `C:\Users\Administrator\.openclaw\skills\minimax-xlsx` +- 已在 `C:\Users\Administrator\.openclaw\openclaw.json` 中补齐 `minimax-xlsx.enabled=true` + +这说明: + +- GitHub skill 本身可以按“完整目录复制”的方式被 OpenClaw 识别 +- 问题不在 skill 格式本身,而在产品代码还没有把“GitHub URL -> skill 安装”做成正式运行时能力 + +## 2. 现状对比 + +### 2.1 已有能力 + +| 能力 | ClawX | zn-ai | 结论 | +| --- | --- | --- | --- | +| Skills 页面与 Marketplace 抽屉 | 有 | 有 | `zn-ai` UI 已有,不需要从零重做 | +| `/api/clawhub/search/install/list` 路由 | 有 | 有 | `zn-ai` Host API 形态已基本对齐 | +| `~/.openclaw/skills` 托管目录 | 有 | 有 | 两个项目共用 OpenClaw skills 根目录 | +| 预装 bundled skills 拷贝 | 有 | 有 | `zn-ai` 已有 preinstalled install 逻辑 | +| 安装后技能扫描/配置读取 | 有 | 有 | `zn-ai` 已有 `skill-config.ts` 基础设施 | + +### 2.2 关键差距 + +| 差距项 | ClawX | zn-ai | 判断 | +| --- | --- | --- | --- | +| `clawhub` 运行时依赖 | 有,`package.json` 直接声明 | 没有 | `zn-ai` marketplace 安装大概率不可用 | +| Marketplace provider 扩展接线 | 有 | 没有 | 这会影响搜索/安装来源能力,但 install-only 迁移可先不搬整套扩展系统 | +| 安装输入类型 | 仅 `slug/version` | 仅 `slug/version` | 两边都不支持 GitHub skill 链接直装 | +| GitHub URL 解析与整目录下载 | 没有 | 没有 | 这是“通过对话贴链接安装”的核心缺口 | +| Skills 路径文案一致性 | 一致 | 不一致,前端 fallback 仍写 `~/.zn-ai/skills` | 会误导手动安装与报错提示 | + +### 2.3 关键证据 + +ClawX 现状: + +- `ClawX/package.json` 已声明 `clawhub` 依赖 +- `ClawX/electron/gateway/clawhub.ts` 的运行时安装参数只有 `slug/version/force` +- `ClawX/electron/main/index.ts` 会给 `ClawHubService` 接入 marketplace provider + +zn-ai 现状: + +- `zn-ai/src/pages/Skills/index.tsx` 与 `MarketplaceDrawer.tsx` 已具备搜索/安装 UI +- `zn-ai/electron/api/routes/skills.ts` 已具备 `/api/clawhub/install` +- `zn-ai/electron/gateway/clawhub.ts` 已有 `ClawHubService` 外壳 +- `zn-ai/package.json` 没有 `clawhub` 依赖,导致运行时 CLI 入口无法成立 +- `zn-ai/src/lib/skills-api.ts` 与 `src/pages/Skills/index.tsx` 的 fallback 路径仍写成 `~/.zn-ai/skills` + +## 3. 迁移范围 + +本计划只覆盖“安装 skill”闭环,严格排除以下范围: + +- 不改 Skills 详情页视觉样式 +- 不扩展 API Key / env 配置能力 +- 不处理 skill 导入导出 +- 不迁移 ClawX 全量 extension framework +- 不重构其它插件、频道、模型、任务执行逻辑 + +本次必须覆盖的范围只有四件事: + +1. 让 `zn-ai` 先真正具备可工作的 marketplace slug 安装能力 +2. 增加 GitHub skill 链接/仓库路径安装能力 +3. 让“通过对话安装 skill”有清晰的运行时入口 +4. 安装完成后自动启用、刷新、回显结果 + +## 4. 目标闭环 + +迁移完成后,`zn-ai` 至少要满足下面的用户路径: + +### 4.1 Marketplace 路径 + +- 用户在 Skills 页面搜索 marketplace +- 点击安装 +- 应用把 skill 安装到 `~/.openclaw/skills/` +- 自动写入 enabled 配置 +- 页面刷新后能看到 skill,且状态为已启用 + +### 4.2 GitHub 链接路径 + +- 用户在对话中粘贴 `https://github.com///blob///SKILL.md` +- 系统把它解析为 `repo/ref/repoPath` +- 下载的是“整个 skill 目录”,不是单个 `SKILL.md` +- 校验目录中存在 `SKILL.md` +- 复制到 `~/.openclaw/skills/` +- 自动启用并返回安装结果、实际路径、后续提示 + +### 4.3 失败时的最低可用体验 + +- 链接无效时,明确提示“不是可安装的 skill 目录链接” +- 目标目录已存在时,给出“已安装/是否覆盖”提示 +- 网络失败时,提示下载失败与建议重试 +- 目录缺 `SKILL.md` 时,提示“不是合法 skill 包” + +## 5. 推荐设计 + +## 5.1 不把 install 继续绑定死在 `clawhub` + +当前 `zn-ai` 的 `/api/clawhub/install` 天然只适合 marketplace slug。 +如果要支持 GitHub skill 链接,建议把安装能力上提为统一安装服务: + +```ts +type SkillInstallRequest = + | { kind: 'marketplace'; slug: string; version?: string; force?: boolean } + | { kind: 'github-url'; url: string; force?: boolean } + | { kind: 'github-repo-path'; repo: string; ref?: string; path: string; name?: string; force?: boolean }; +``` + +建议新增: + +- `electron/services/skill-install.ts` + +由它统一负责: + +- 解析安装源 +- 下载或 checkout skill 目录 +- 校验 `SKILL.md` +- 写入 `~/.openclaw/skills` +- 更新 `openclaw.json` +- 返回安装结果 + +`/api/clawhub/install` 可以继续保留,但只作为 marketplace 的兼容入口,内部调用统一安装服务。 + +## 5.2 GitHub 安装逻辑建议直接落在 Electron TypeScript + +不建议把 Python helper script 直接搬进 `zn-ai` 运行时。 +建议在 Electron 侧用 TypeScript 实现,参考两类现有逻辑: + +- GitHub 安装语义:参考本地 `skill-installer` 的 `install-skill-from-github.py` +- skill 目录复制语义:参考 `ClawX/scripts/bundle-preinstalled-skills.mjs` + +运行时 GitHub install 最小步骤: + +1. 解析 GitHub URL +2. 把 `blob/.../SKILL.md` 还原为 skill 目录路径 +3. 下载 zip 或 sparse checkout +4. 定位 skill 目录 +5. 校验 `SKILL.md` +6. 拷贝到 `~/.openclaw/skills/` +7. 写入 `openclaw.json -> skills.entries[slug].enabled = true` +8. 返回 `{ success, slug, baseDir, source }` + +## 5.3 “通过对话安装”优先走 tool,而不是字符串黑魔法 + +`zn-ai` 现有聊天链路已经能承载 `tool_use/tool_result` 消息块。 +因此推荐加一个明确的 host-side tool,例如: + +```ts +skills.install({ + source: 'marketplace' | 'github-url' | 'github-repo-path', + slug?: string, + version?: string, + url?: string, + repo?: string, + ref?: string, + path?: string, + force?: boolean +}) +``` + +这样做的好处: + +- 不需要在聊天文本里做脆弱的正则硬解析 +- Renderer 已经能展示 tool 过程与结果 +- Skills 页面和聊天都能复用同一个安装服务 + +这次迁移里,不需要重做整个聊天架构,只要补一个 install skill tool 接口即可。 + +## 5.4 install-only 阶段不迁移 ClawX 扩展系统 + +ClawX 有 marketplace provider extension 接线。 +但本次目标只是把“安装 skill”闭环跑通,不建议为了这一个功能把 extension registry 整套引入 `zn-ai`。 + +install-only 阶段建议: + +- 先直接依赖 `clawhub` CLI 完成 marketplace 安装 +- 再新增 GitHub URL install 适配器 +- extension marketplace provider 以后如果要做 ClawHub 中国镜像或自定义市场,再单独立项 + +## 6. 分阶段迁移计划 + +### Phase 1:补齐运行时前提 + +目标:让 `zn-ai` marketplace install 至少不是空壳。 + +工作项: + +- 在 `zn-ai/package.json` 增加 `clawhub` 依赖 +- 校验 `electron/utils/paths.ts` 的 `getClawHubCliBinPath/getClawHubCliEntryPath` +- 让 `electron/gateway/clawhub.ts` 的 capability 检查在依赖缺失时返回明确错误 +- 修正 `src/lib/skills-api.ts` 与 `src/pages/Skills/index.tsx` 中的 `~/.zn-ai/skills` fallback 为 `~/.openclaw/skills` + +验收: + +- `GET /api/clawhub/capability` 返回 `canInstall=true` +- Skills 页面点击 Install 不再因为缺 CLI 直接失败 + +### Phase 2:引入统一 Skill 安装服务 + +目标:把 install 从“只会装 slug”升级为“可装多来源”。 + +工作项: + +- 新建 `electron/services/skill-install.ts` +- 支持 marketplace 与 GitHub 两类安装源 +- 抽出公共能力: + - `parseGitHubSkillUrl` + - `downloadOrCheckoutSkillDir` + - `validateSkillDir` + - `copySkillIntoManagedDir` + - `enableInstalledSkill` +- 统一返回安装结果对象 + +验收: + +- 传入 `slug` 时仍能成功安装 marketplace skill +- 传入 GitHub `blob/.../SKILL.md` 链接时能成功安装整个目录 + +### Phase 3:接通 Host API 与前端安装入口 + +目标:让 UI 和聊天都能调统一安装服务。 + +工作项: + +- 新增 `POST /api/skills/install` +- 保留 `POST /api/clawhub/install` 作为兼容入口 +- `src/lib/skills-api.ts` 改成调用统一 install API +- Skills 页面安装成功后继续执行: + - enable + - reload skills + - toast success +- 在 MarketplaceDrawer 增加一个“粘贴 GitHub skill 链接”入口 + - 这一步只属于 install 能力,不算额外 UI 扩张 + +验收: + +- Skills 页面支持两种安装方式: + - marketplace 搜索安装 + - GitHub 链接直装 + +### Phase 4:接通聊天 tool 与回归验证 + +目标:完成“通过对话安装 skill”。 + +工作项: + +- 在网关/运行时工具桥里新增 `skills.install` +- 让模型遇到 GitHub skill 链接时可以直接调用该 tool +- Tool result 回传: + - `slug` + - `installedPath` + - `enabled` + - `source` + - `nextStep` +- 增加安装相关 smoke case + +验收: + +- 用户在对话里发送 GitHub skill 链接 +- 模型触发 `skills.install` +- 安装完成后页面和运行时都能识别该 skill + +## 7. 推荐 Sub-Agent 编制 + +本次 install-only 迁移,建议 **4 个开发 sub-agent + 1 个主协调 agent**。 + +如果少于 4 个,很容易出现: + +- 后端安装服务已写好,但聊天入口迟迟没接 +- marketplace slug 安装恢复了,但 GitHub URL 安装仍不可用 +- UI 提示和实际托管目录不一致,影响手动兜底 + +### SA-1:Runtime Installer + +职责: + +- 负责 `electron/services/skill-install.ts` +- 负责 GitHub URL 解析、下载/checkout、目录校验、复制安装 +- 负责安装后启用配置写入 + +文件责任边界: + +- `electron/services/skill-install.ts` +- `electron/utils/skill-config.ts` +- 必要时 `electron/utils/paths.ts` + +### SA-2:Marketplace & Host API + +职责: + +- 给 `zn-ai` 补 `clawhub` 运行时依赖 +- 接通 capability 检查 +- 调整 `/api/clawhub/install` 与新增 `/api/skills/install` +- 保持 marketplace slug 安装向后兼容 + +文件责任边界: + +- `package.json` +- `electron/api/routes/skills.ts` +- `electron/gateway/clawhub.ts` +- `src/lib/skills-api.ts` + +### SA-3:Chat Tool Bridge + +职责: + +- 给聊天链路增加 `skills.install` tool +- 负责“通过对话贴 GitHub skill 链接即可安装”的入口 +- 负责 tool result 事件与前端可见反馈 + +文件责任边界: + +- `electron/gateway/*` +- 运行时/类型定义文件 +- 与聊天消息 tool block 相关的 renderer 连接层 + +### SA-4:Skills UI & QA + +职责: + +- 修 Skills 页安装入口文案和 fallback 路径 +- 加 GitHub skill 链接安装入口 +- 补手工 smoke checklist、失败态提示、回归文档 + +文件责任边界: + +- `src/pages/Skills/index.tsx` +- `src/pages/Skills/components/MarketplaceDrawer.tsx` +- `src/i18n/locales/*/skills.json` +- `docs/*` +- tests / smoke scripts + +### 主协调 agent + +职责: + +- 维护安装请求契约 +- 控制范围不外溢到 skill 详情/配置等无关功能 +- 处理 agent 间接口对齐与回归顺序 + +## 8. 推荐推进顺序 + +1. 先做 Phase 1,确保 `zn-ai` 不再是“安装 UI 有了,但执行器缺失” +2. 再做 Phase 2,把安装服务抽象成统一入口 +3. 然后并行推进: + - SA-2 接 Host API + - SA-3 接聊天 tool + - SA-4 接 Skills 页面 +4. 最后统一做一次 GitHub skill 链接实装验证 + +推荐实装验证用例就用这次的目标 skill: + +- `https://github.com/MiniMax-AI/skills/blob/main/skills/minimax-xlsx/SKILL.md` + +原因: + +- 它不是纯 `SKILL.md`,目录里有 `scripts/ references/ templates/` +- 能很好验证“必须安装完整 skill 目录”的约束 + +## 9. 风险与控制 + +### 风险 1:把 GitHub `SKILL.md` 当成单文件安装 + +后果: + +- skill 看起来“装上了”,实际运行时找不到脚本和模板 + +控制: + +- 安装器必须强制校验 skill 目录,不接受单文件安装 + +### 风险 2:覆盖用户已有 skill 目录 + +后果: + +- 覆盖用户定制内容 + +控制: + +- 默认禁止覆盖 +- 返回 “destination exists” +- 只有 `force=true` 才允许升级或替换 + +### 风险 3:路径文案与真实目录不一致 + +后果: + +- 手动安装指导错误 + +控制: + +- 所有 install 相关提示统一使用 `~/.openclaw/skills` + +### 风险 4:scope 扩散 + +后果: + +- 为了 install 迁移,顺带重做整页 Skills 管理 + +控制: + +- install-only 原则 +- 不改 detail/config 非必要逻辑 + +## 10. 完成标准 + +迁移完成后,至少满足以下标准: + +- `zn-ai` 运行时包含可用的 `clawhub` 执行器 +- Skills 页面可安装 marketplace skill +- Skills 页面可通过 GitHub skill 链接安装 skill +- 对话中贴 GitHub skill 链接可触发安装 +- 安装结果会落到 `~/.openclaw/skills/` +- `openclaw.json` 中会自动启用该 skill +- 安装完成后,技能列表能刷新并可见 +- 失败态提示明确,不再把错误归因为泛化的 “Install failed” + diff --git a/docs/prompt-history.md b/docs/prompt-history.md index f6405af..0e53aaa 100644 --- a/docs/prompt-history.md +++ b/docs/prompt-history.md @@ -16,4 +16,6 @@ - 在ClwaX项目中深度分析消息频道功能包括渲染层视觉UI、主进程等实现思路,用于迁移到zn-ai项目,输出迁移开发计划到zn-ai/docs目录下,估算sub-agent数量,安排sub-agent分工分析消息频道功能实现思路,期望迁移功能对齐ClawX。 -- 重构KnowLedge/index.tsx渲染层、主进程,产品需求如下:上传文件按钮,上传到zn-ai/docs目录,文件列表显示内容:文件名称,文件大小、修改日期、文件类型,操作:删除,视觉UI沿用当前的风格。规划重构计划,估算sub-agent数量,安排sub-agent分工推进工作 \ No newline at end of file +- 重构KnowLedge/index.tsx渲染层、主进程,产品需求如下:上传文件按钮,上传到zn-ai/docs目录,文件列表显示内容:文件名称,文件大小、修改日期、文件类型,操作:删除,视觉UI沿用当前的风格。规划重构计划,估算sub-agent数量,安排sub-agent分工推进工作 + +- 在ClawX项目中,通过对话,https://github.com/MiniMax-AI/skills/blob/main/skills/minimax-xlsx/SKILL.md,帮我安装这个skill,能正确安装。在zn-ai项目中,是否也能有同样的功能呢?帮我对比下,如果zn-ai项目没有安装skill的功能,就规划功能迁移计划到zn-ai/docs目录下,估算sub-agent数量,安排sub-agent分工推进迁移安装skill功能,跟安装skill无关的功能先不考虑修改调整。 \ No newline at end of file diff --git a/electron/api/routes/skills.ts b/electron/api/routes/skills.ts index f40b95e..09f2e8a 100644 --- a/electron/api/routes/skills.ts +++ b/electron/api/routes/skills.ts @@ -5,12 +5,18 @@ import { fail, ok, parseJsonBody } from '../route-utils'; import { getAllSkillConfigs, updateSkillConfig } from '../../utils/skill-config'; import { shell } from 'electron'; import { getOpenClawConfigDir } from '../../utils/paths'; +import { + SkillInstallService, + SkillInstallServiceError, + type SkillInstallRequest, +} from '@service/skill-install-service'; export async function handleSkillRoutes( request: NormalizedHostApiRequest, ctx: HostApiContext, ): Promise | null> { const { pathname, method } = request; + const installService = new SkillInstallService({ clawHubService: ctx.clawHubService }); if (pathname === '/api/skills/configs' && method === 'GET') { try { @@ -65,13 +71,26 @@ export async function handleSkillRoutes( } } + if (pathname === '/api/skills/install' && method === 'POST') { + try { + const body = parseJsonBody(request.body); + return ok(await installService.install(body)); + } catch (error) { + return failInstall(error); + } + } + if (pathname === '/api/clawhub/install' && method === 'POST') { try { - const body = parseJsonBody>(request.body); - await ctx.clawHubService.install(body as { slug: string; version?: string; force?: boolean }); - return ok({ success: true }); + const body = parseJsonBody<{ slug: string; version?: string; force?: boolean }>(request.body); + return ok(await installService.install({ + kind: 'marketplace', + slug: body.slug, + version: body.version, + force: body.force, + })); } catch (error) { - return fail(500, error instanceof Error ? error.message : String(error)); + return failInstall(error); } } @@ -150,3 +169,11 @@ export async function handleSkillRoutes( return null; } + +function failInstall(error: unknown): HostApiResult { + if (error instanceof SkillInstallServiceError) { + return fail(error.status, error.message); + } + + return fail(500, error instanceof Error ? error.message : String(error)); +} diff --git a/electron/service/skill-install-service.ts b/electron/service/skill-install-service.ts new file mode 100644 index 0000000..c315ec7 --- /dev/null +++ b/electron/service/skill-install-service.ts @@ -0,0 +1,458 @@ +import { spawn } from 'node:child_process'; +import { createWriteStream, existsSync } from 'node:fs'; +import { cp, mkdir, mkdtemp, readFile, readdir, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { basename, dirname, join, resolve, sep } from 'node:path'; +import { pipeline } from 'node:stream/promises'; +import axios from 'axios'; +import extractZip from 'extract-zip'; +import type { ClawHubService } from '@electron/gateway/clawhub'; +import { updateSkillConfig } from '@electron/utils/skill-config'; +import { ensureDir, getOpenClawConfigDir } from '@electron/utils/paths'; + +export type SkillInstallRequest = + | { kind: 'marketplace'; slug: string; version?: string; force?: boolean } + | { kind: 'github-url'; url: string; force?: boolean }; + +export type SkillInstallSource = 'marketplace' | 'github-url'; + +export interface SkillInstallResult { + success: true; + slug: string; + baseDir: string; + source: SkillInstallSource; + enabled: true; +} + +export interface ParsedGitHubSkillUrl { + owner: string; + repo: string; + ref: string; + skillPath: string; + defaultSlug: string; + archiveUrl: string; + repositoryUrl: string; + originalUrl: string; +} + +type MarketplaceInstaller = Pick; + +type EnableSkillFn = (skillKey: string) => Promise; +type ResolveGitHubSkillDirFn = (source: ParsedGitHubSkillUrl, tempRoot: string) => Promise; + +interface SkillInstallServiceOptions { + clawHubService: MarketplaceInstaller; + configDir?: string; + skillsRootDir?: string; + createTempDir?: () => Promise; + enableSkill?: EnableSkillFn; + resolveGitHubSkillDirectory?: ResolveGitHubSkillDirFn; +} + +export class SkillInstallServiceError extends Error { + constructor( + message: string, + readonly status: number, + readonly code: string, + ) { + super(message); + this.name = 'SkillInstallServiceError'; + } +} + +export class SkillInstallService { + private readonly clawHubService: MarketplaceInstaller; + private readonly configDir: string; + private readonly skillsRootDir: string; + private readonly createTempDir: () => Promise; + private readonly enableSkill: EnableSkillFn; + private readonly resolveGitHubSkillDirectory: ResolveGitHubSkillDirFn; + + constructor(options: SkillInstallServiceOptions) { + this.clawHubService = options.clawHubService; + this.configDir = options.configDir ?? getOpenClawConfigDir(); + this.skillsRootDir = options.skillsRootDir ?? join(this.configDir, 'skills'); + this.createTempDir = options.createTempDir ?? (() => mkdtemp(join(tmpdir(), 'zn-ai-skill-install-'))); + this.enableSkill = options.enableSkill ?? defaultEnableSkill; + this.resolveGitHubSkillDirectory = options.resolveGitHubSkillDirectory ?? defaultResolveGitHubSkillDirectory; + } + + async install(request: SkillInstallRequest): Promise { + this.ensureInstallLayout(); + + if (request.kind === 'marketplace') { + return this.installMarketplaceSkill(request); + } + + return this.installGitHubSkill(request); + } + + private ensureInstallLayout(): void { + ensureDir(this.configDir); + ensureDir(this.skillsRootDir); + } + + private async installMarketplaceSkill( + request: Extract, + ): Promise { + const slug = normalizeSkillSlug(request.slug); + const baseDir = join(this.skillsRootDir, slug); + + if (existsSync(baseDir) && request.force !== true) { + throw new SkillInstallServiceError( + `Skill "${slug}" is already installed. Use force to overwrite it.`, + 409, + 'skill_exists', + ); + } + + await this.clawHubService.install({ + slug, + version: request.version, + force: request.force, + }); + + const manifestPath = join(baseDir, 'SKILL.md'); + if (!existsSync(manifestPath)) { + throw new SkillInstallServiceError( + `Installed skill "${slug}" is missing SKILL.md.`, + 500, + 'missing_skill_manifest', + ); + } + + await this.enableSkill(slug); + + return { + success: true, + slug, + baseDir, + source: 'marketplace', + enabled: true, + }; + } + + private async installGitHubSkill( + request: Extract, + ): Promise { + const source = parseGitHubSkillUrl(request.url); + const tempRoot = await this.createTempDir(); + + try { + const sourceSkillDir = await this.resolveGitHubSkillDirectory(source, tempRoot); + const manifestPath = join(sourceSkillDir, 'SKILL.md'); + + if (!existsSync(manifestPath)) { + throw new SkillInstallServiceError( + 'SKILL.md not found in the downloaded skill directory.', + 400, + 'missing_skill_manifest', + ); + } + + const slug = await resolveSkillSlug(manifestPath, source.defaultSlug); + const baseDir = join(this.skillsRootDir, slug); + + await prepareDestination(baseDir, request.force === true); + await cp(sourceSkillDir, baseDir, { recursive: true, force: true }); + await this.enableSkill(slug); + + return { + success: true, + slug, + baseDir, + source: 'github-url', + enabled: true, + }; + } finally { + await rm(tempRoot, { recursive: true, force: true }).catch(() => undefined); + } + } +} + +export function parseGitHubSkillUrl(url: string): ParsedGitHubSkillUrl { + const trimmedUrl = String(url || '').trim(); + if (!trimmedUrl) { + throw new SkillInstallServiceError('GitHub skill URL is required.', 400, 'invalid_github_url'); + } + + let parsed: URL; + try { + parsed = new URL(trimmedUrl); + } catch { + throw new SkillInstallServiceError('GitHub skill URL is invalid.', 400, 'invalid_github_url'); + } + + if (parsed.protocol !== 'https:' || parsed.hostname !== 'github.com') { + throw new SkillInstallServiceError( + 'Only public github.com skill URLs are supported.', + 400, + 'invalid_github_url', + ); + } + + const segments = parsed.pathname.split('/').filter(Boolean).map((segment) => decodeURIComponent(segment)); + if (segments.length < 4) { + throw new SkillInstallServiceError( + 'GitHub skill URL must point to /blob//.../SKILL.md or /tree//....', + 400, + 'invalid_github_url', + ); + } + + const [owner, repo, mode, ref, ...rest] = segments; + validateGitHubPathSegment(owner, 'owner'); + validateGitHubPathSegment(repo, 'repository'); + validateGitHubPathSegment(ref, 'ref'); + + if (mode !== 'blob' && mode !== 'tree') { + throw new SkillInstallServiceError( + 'GitHub skill URL must use either /blob//.../SKILL.md or /tree//....', + 400, + 'invalid_github_url', + ); + } + + const normalizedRest = rest.map((segment) => { + validateGitHubPathSegment(segment, 'skill path'); + return segment; + }); + + if (mode === 'blob') { + if (normalizedRest.length === 0 || normalizedRest[normalizedRest.length - 1] !== 'SKILL.md') { + throw new SkillInstallServiceError( + 'GitHub blob URL must point to a SKILL.md file.', + 400, + 'invalid_github_url', + ); + } + + const directorySegments = normalizedRest.slice(0, -1); + const skillPath = directorySegments.join('/'); + return { + owner, + repo, + ref, + skillPath, + defaultSlug: directorySegments[directorySegments.length - 1] || repo, + archiveUrl: buildGitHubArchiveUrl(owner, repo, ref), + repositoryUrl: `https://github.com/${owner}/${repo}.git`, + originalUrl: trimmedUrl, + }; + } + + if (normalizedRest.length === 0) { + throw new SkillInstallServiceError( + 'GitHub tree URL must point to a skill directory.', + 400, + 'invalid_github_url', + ); + } + + return { + owner, + repo, + ref, + skillPath: normalizedRest.join('/'), + defaultSlug: normalizedRest[normalizedRest.length - 1], + archiveUrl: buildGitHubArchiveUrl(owner, repo, ref), + repositoryUrl: `https://github.com/${owner}/${repo}.git`, + originalUrl: trimmedUrl, + }; +} + +function buildGitHubArchiveUrl(owner: string, repo: string, ref: string): string { + return `https://api.github.com/repos/${owner}/${repo}/zipball/${encodeURIComponent(ref)}`; +} + +function validateGitHubPathSegment(segment: string, label: string): void { + const normalized = String(segment || '').trim(); + if (!normalized || normalized === '.' || normalized === '..' || normalized.includes('/') || normalized.includes('\\')) { + throw new SkillInstallServiceError( + `GitHub ${label} segment is invalid.`, + 400, + 'invalid_github_url', + ); + } +} + +function normalizeSkillSlug(slug: string): string { + const normalized = String(slug || '').trim(); + if (!normalized) { + throw new SkillInstallServiceError('Skill slug is required.', 400, 'invalid_skill_slug'); + } + if (normalized === '.' || normalized === '..' || normalized.includes('/') || normalized.includes('\\')) { + throw new SkillInstallServiceError('Skill slug contains an invalid path segment.', 400, 'invalid_skill_slug'); + } + return normalized; +} + +async function resolveSkillSlug(manifestPath: string, fallbackSlug: string): Promise { + const raw = await readFile(manifestPath, 'utf-8'); + const frontmatterMatch = raw.match(/^---\s*\n([\s\S]*?)\n---/); + const nameMatch = frontmatterMatch?.[1]?.match(/^\s*name\s*:\s*["']?([^"'\n]+)["']?\s*$/m); + return normalizeSkillSlug(nameMatch?.[1] || fallbackSlug); +} + +async function defaultEnableSkill(skillKey: string): Promise { + const result = await updateSkillConfig(skillKey, { enabled: true }); + if (!result.success) { + throw new SkillInstallServiceError( + result.error || `Failed to enable skill "${skillKey}".`, + 500, + 'enable_skill_failed', + ); + } +} + +async function prepareDestination(baseDir: string, force: boolean): Promise { + if (existsSync(baseDir)) { + if (!force) { + throw new SkillInstallServiceError( + `Skill "${basename(baseDir)}" is already installed. Use force to overwrite it.`, + 409, + 'skill_exists', + ); + } + + await rm(baseDir, { recursive: true, force: true }); + } + + await mkdir(dirname(baseDir), { recursive: true }); +} + +async function defaultResolveGitHubSkillDirectory( + source: ParsedGitHubSkillUrl, + tempRoot: string, +): Promise { + const archivePath = join(tempRoot, 'github-skill.zip'); + const extractedRoot = join(tempRoot, 'zip'); + + try { + await downloadGitHubArchive(source.archiveUrl, archivePath); + await mkdir(extractedRoot, { recursive: true }); + await extractZip(archivePath, { dir: extractedRoot }); + const repoRoot = await resolveExtractedRepoRoot(extractedRoot); + return resolveSkillDirectory(repoRoot, source.skillPath); + } catch (archiveError) { + try { + const cloneDir = join(tempRoot, 'sparse'); + await sparseCheckoutGitHubSkill(source, cloneDir); + return resolveSkillDirectory(cloneDir, source.skillPath); + } catch (gitError) { + throw new SkillInstallServiceError( + `Failed to download GitHub skill. ZIP attempt failed: ${getErrorMessage(archiveError)}. Sparse checkout fallback failed: ${getErrorMessage(gitError)}.`, + 502, + 'github_download_failed', + ); + } + } +} + +async function downloadGitHubArchive(archiveUrl: string, archivePath: string): Promise { + const response = await axios.get(archiveUrl, { + responseType: 'stream', + headers: { + 'User-Agent': 'zn-ai-skill-installer', + }, + }); + + await pipeline(response.data, createWriteStream(archivePath)); +} + +async function resolveExtractedRepoRoot(extractedRoot: string): Promise { + const entries = await readdir(extractedRoot, { withFileTypes: true }); + const directories = entries.filter((entry) => entry.isDirectory()); + + if (directories.length === 1) { + return join(extractedRoot, directories[0].name); + } + + if (existsSync(join(extractedRoot, 'SKILL.md'))) { + return extractedRoot; + } + + throw new SkillInstallServiceError( + 'Downloaded GitHub archive has an unexpected directory structure.', + 502, + 'github_archive_invalid', + ); +} + +function resolveSkillDirectory(rootDir: string, skillPath: string): string { + const targetDir = skillPath ? resolve(rootDir, skillPath) : rootDir; + const resolvedRootDir = resolve(rootDir); + const normalizedRoot = `${resolvedRootDir}${sep}`; + const normalizedTarget = resolve(targetDir); + + if (normalizedTarget !== resolvedRootDir && !normalizedTarget.startsWith(normalizedRoot)) { + throw new SkillInstallServiceError( + 'Resolved GitHub skill path escapes the repository root.', + 400, + 'invalid_github_url', + ); + } + + if (!existsSync(normalizedTarget)) { + throw new SkillInstallServiceError( + `Skill directory "${skillPath || '.'}" was not found in the repository.`, + 404, + 'github_skill_path_not_found', + ); + } + + return normalizedTarget; +} + +async function sparseCheckoutGitHubSkill(source: ParsedGitHubSkillUrl, cloneDir: string): Promise { + if (source.skillPath) { + await runGitCommand(['clone', '--filter=blob:none', '--sparse', source.repositoryUrl, cloneDir]); + await runGitCommand(['-C', cloneDir, 'sparse-checkout', 'set', source.skillPath]); + } else { + await runGitCommand(['clone', '--filter=blob:none', source.repositoryUrl, cloneDir]); + } + + await runGitCommand(['-C', cloneDir, 'fetch', '--depth', '1', 'origin', source.ref]); + await runGitCommand(['-C', cloneDir, 'checkout', 'FETCH_HEAD']); +} + +async function runGitCommand(args: string[]): Promise { + await new Promise((resolvePromise, rejectPromise) => { + const child = spawn('git', args, { + windowsHide: true, + env: { + ...process.env, + CI: 'true', + FORCE_COLOR: '0', + }, + }); + + let stdout = ''; + let stderr = ''; + + child.stdout.on('data', (chunk) => { + stdout += String(chunk); + }); + + child.stderr.on('data', (chunk) => { + stderr += String(chunk); + }); + + child.on('error', (error) => { + rejectPromise(error); + }); + + child.on('close', (code) => { + if (code === 0) { + resolvePromise(); + return; + } + + rejectPromise(new Error(stderr.trim() || stdout.trim() || `git exited with code ${code ?? 'unknown'}`)); + }); + }); +} + +function getErrorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} diff --git a/package.json b/package.json index fbc3fc4..5abd83d 100644 --- a/package.json +++ b/package.json @@ -105,6 +105,7 @@ "bytenode": "^1.5.7", "chromium-bidi": "^15.0.0", "class-variance-authority": "^0.7.1", + "clawhub": "^0.9.0", "clsx": "^2.1.1", "codemirror": "^6.0.2", "crypto": "^1.0.1", @@ -115,6 +116,7 @@ "electron-squirrel-startup": "^1.0.1", "electron-store": "^11.0.2", "electron-updater": "^6.8.3", + "extract-zip": "^2.0.1", "framer-motion": "^12.38.0", "highlight.js": "^11.11.1", "i18next": "^26.0.5", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index fc5c435..805553c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -74,6 +74,9 @@ importers: class-variance-authority: specifier: ^0.7.1 version: 0.7.1 + clawhub: + specifier: ^0.9.0 + version: 0.9.0 clsx: specifier: ^2.1.1 version: 2.1.1 @@ -104,6 +107,9 @@ importers: electron-updater: specifier: ^6.8.3 version: 6.8.3 + extract-zip: + specifier: ^2.0.1 + version: 2.0.1 framer-motion: specifier: ^12.38.0 version: 12.38.0(react-dom@19.2.5(react@19.2.5))(react@19.2.5) @@ -314,6 +320,12 @@ packages: '@anthropic-ai/vertex-sdk@0.15.0': resolution: {integrity: sha512-i2LDdu6VB8Lqqip+kbNSXRxQgFsCg6GPBO/X2zRJwLl99dNzf28nb6Rdi0EodONXsyJfY2TKdGR+y5l1/AKFEg==} + '@ark/schema@0.56.0': + resolution: {integrity: sha512-ECg3hox/6Z/nLajxXqNhgPtNdHWC9zNsDyskwO28WinoFEnWow4IsERNz9AnXRhTZJnYIlAJ4uGn3nlLk65vZA==} + + '@ark/util@0.56.0': + resolution: {integrity: sha512-BghfRC8b9pNs3vBoDJhcta0/c1J1rsoS1+HgVUreMFPdhz/CRAKReAu57YEllNaSy98rWAdY1gE+gFup7OXpgA==} + '@asamuzakjp/css-color@5.1.11': resolution: {integrity: sha512-KVw6qIiCTUQhByfTd78h2yD1/00waTmm9uy/R7Ck/ctUyAPj+AEDLkQIdJW0T8+qGgj3j5bpNKK7Q3G+LedJWg==} engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} @@ -3188,8 +3200,8 @@ packages: link-preview-js: optional: true - '@whiskeysockets/libsignal-node@https://codeload.github.com/whiskeysockets/libsignal-node/tar.gz/1c30d7d7e76a3b0aa120b04dc6a26f5a12dccf67': - resolution: {tarball: https://codeload.github.com/whiskeysockets/libsignal-node/tar.gz/1c30d7d7e76a3b0aa120b04dc6a26f5a12dccf67} + '@whiskeysockets/libsignal-node@git+https://github.com/whiskeysockets/libsignal-node.git#1c30d7d7e76a3b0aa120b04dc6a26f5a12dccf67': + resolution: {commit: 1c30d7d7e76a3b0aa120b04dc6a26f5a12dccf67, repo: https://github.com/whiskeysockets/libsignal-node.git, type: git} version: 2.0.1 '@xmldom/xmldom@0.8.12': @@ -3322,6 +3334,12 @@ packages: resolution: {integrity: sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw==} engines: {node: '>= 0.4'} + arkregex@0.0.5: + resolution: {integrity: sha512-ncYjBdLlh5/QnVsAA8De16Tc9EqmYM7y/WU9j+236KcyYNUXogpz3sC4ATIZYzzLxwI+0sEOaQLEmLmRleaEXw==} + + arktype@2.2.0: + resolution: {integrity: sha512-t54MZ7ti5BhOEvzEkgKnWvqj+UbDfWig+DHr5I34xatymPusKLS0lQpNJd8M6DzmIto2QGszHfNKoFIT8tMCZQ==} + array-back@3.1.0: resolution: {integrity: sha512-TkuxA4UCOvxuDK6NZYXCalszEzj+TLszyASooky+i742l9TqsOdYCMJJupxRic61hwquNtppB3hgcuq9SVSH1Q==} engines: {node: '>=6'} @@ -3608,10 +3626,19 @@ packages: class-variance-authority@0.7.1: resolution: {integrity: sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg==} + clawhub@0.9.0: + resolution: {integrity: sha512-p4qFJ84qF194KlGj0LlnuggPk0kKRgbp1wN27aJnRQ5FkwXlEalGqw8wngG2Ghca7q6vbvyVoI4V4KDv2zJWdQ==} + engines: {node: '>=20'} + hasBin: true + cli-cursor@3.1.0: resolution: {integrity: sha512-I/zHAwsKf9FqGoXM4WWRACob9+SNukZTd94DWF57E4toouRulbCxcUh6RKUEOQlYTHJnzkPMySvPNaaSLNfLZw==} engines: {node: '>=8'} + cli-cursor@5.0.0: + resolution: {integrity: sha512-aCj4O5wKyszjMmDT4tZj93kxyydN/K5zPWSCe6/0AV/AA1pqe5ZBIw0a2ZfPQV7lL5/yb5HsUreJ6UFAF1tEQw==} + engines: {node: '>=18'} + cli-highlight@2.1.11: resolution: {integrity: sha512-9KDcoEVwyUXrjcJNvHD0NFc/hiwe/WPVYIleQh2O1N2Zro5gWJZ/K+3DGn8w8P/F6FxOgzyC5bxDyHIgCSPhGg==} engines: {node: '>=8.0.0', npm: '>=5.0.0'} @@ -3621,6 +3648,10 @@ packages: resolution: {integrity: sha512-ywqV+5MmyL4E7ybXgKys4DugZbX0FC6LnwrhjuykIjnK9k8OQacQ7axGKnjDXWNhns0xot3bZI5h55H8yo9cJg==} engines: {node: '>=6'} + cli-spinners@3.4.0: + resolution: {integrity: sha512-bXfOC4QcT1tKXGorxL3wbJm6XJPDqEnij2gQ2m7ESQuE+/z9YFIWnl/5RpTiKWbMq3EVKR4fRLJGn6DVfu0mpw==} + engines: {node: '>=18.20'} + cli-truncate@2.1.0: resolution: {integrity: sha512-n8fOixwDD6b/ObinzTrp1ZKFzbgvKZvuz/TvejnLn1aQfC6r52XEx85FmuC+3HI+JM7coBRXUvNqEU2PHVrHpg==} engines: {node: '>=8'} @@ -4330,6 +4361,9 @@ packages: resolution: {integrity: sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ==} engines: {node: ^12.20 || >= 14.13} + fflate@0.8.2: + resolution: {integrity: sha512-cPJU47OaAoCbg0pBvzsgpTPhmhqI5eJjh/JIu8tPj5q+T7iLvW/JAYUqmE7KOB4R1ZyEhzBaIQpQpardBF5z8A==} + file-type@21.3.4: resolution: {integrity: sha512-Ievi/yy8DS3ygGvT47PjSfdFoX+2isQueoYP1cntFW1JLYAuS4GD7NUPGg4zv2iZfV52uDyk5w5Z0TdpRS6Q1g==} engines: {node: '>=20'} @@ -4801,6 +4835,10 @@ packages: resolution: {integrity: sha512-2HvIEKRoqS62guEC+qBjpvRubdX910WCMuJTZ+I9yvqKU2/12eSL549HMwtabb4oupdj2sMP50k+XJfB/8JE6w==} engines: {node: '>=8'} + is-interactive@2.0.0: + resolution: {integrity: sha512-qP1vozQRI+BMOPcjFzrjXuQvdak2pHNUMZoeG2eRbiSqyvbEf/wQtEOTOX1guk6E3t36RkaqiSt8A/6YElNxLQ==} + engines: {node: '>=12'} + is-network-error@1.3.1: resolution: {integrity: sha512-6QCxa49rQbmUWLfk0nuGqzql9U8uaV2H6279bRErPBHe/109hCzsLUBUHfbEtvLIHBd6hyXbgedBSHevm43Edw==} engines: {node: '>=16'} @@ -4830,6 +4868,10 @@ packages: resolution: {integrity: sha512-knxG2q4UC3u8stRGyAVJCOdxFmv5DZiRcdlIaAQXAbSfJya+OhopNotLQrstBhququ4ZpuKbDc/8S6mgXgPFPw==} engines: {node: '>=10'} + is-unicode-supported@2.1.0: + resolution: {integrity: sha512-mE00Gnza5EEB3Ds0HfMyllZzbBrmLOX3vfWoj9A9PEnTfratQ/BcaJOuMhnkhjXvb2+FkY3VuHqtAGpTPmglFQ==} + engines: {node: '>=18'} + isarray@1.0.0: resolution: {integrity: sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==} @@ -5112,6 +5154,10 @@ packages: resolution: {integrity: sha512-8XPvpAA8uyhfteu8pIvQxpJZ7SYYdpUivZpGy6sFsBuKRY/7rQGavedeB8aK+Zkyq6upMFVL/9AW6vOYzfRyLg==} engines: {node: '>=10'} + log-symbols@7.0.1: + resolution: {integrity: sha512-ja1E3yCr9i/0hmBVaM0bfwDjnGy8I/s6PP4DFp+yP+a+mrHO4Rm7DtmnqROTUkHIkqffC84YY7AeqX6oFk0WFg==} + engines: {node: '>=18'} + log4js@6.9.1: resolution: {integrity: sha512-1somDdy9sChrr9/f4UlzhdaGfDR2c/SaD2a4T7qEkG4jTS57/B3qmnjLYePwQ8cqWnUHZI0iAKxMBpCZICiZ2g==} engines: {node: '>=8.0'} @@ -5394,6 +5440,11 @@ packages: engines: {node: '>=10.0.0'} hasBin: true + mime@4.1.0: + resolution: {integrity: sha512-X5ju04+cAzsojXKes0B/S4tcYtFAJ6tTMuSPBEn9CPGlrWr8Fiw7qYeLT0XyH80HSoAoqWCaz+MWKh22P7G1cw==} + engines: {node: '>=16'} + hasBin: true + mimic-fn@2.1.0: resolution: {integrity: sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==} engines: {node: '>=6'} @@ -5680,6 +5731,10 @@ packages: resolution: {integrity: sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==} engines: {node: '>=6'} + onetime@7.0.0: + resolution: {integrity: sha512-VXJjc87FScF88uafS3JllDgvAm+c/Slfz06lorj2uAY34rlUu0Nt+v8wreiImcrgAjjIHp1rXpTDlLOGw29WwQ==} + engines: {node: '>=18'} + oniguruma-parser@0.12.2: resolution: {integrity: sha512-6HVa5oIrgMC6aA6WF6XyyqbhRPJrKR02L20+2+zpDtO5QAzGHAUGw5TKQvwi5vctNnRHkJYmjAhRVQF2EKdTQw==} @@ -5745,6 +5800,10 @@ packages: resolution: {integrity: sha512-5b6Y85tPxZZ7QytO+BQzysW31HJku27cRIlkbAXaNx+BdcVi+LlRFmVXzeF6a7JCwJpyw5c4b+YSVImQIrBpuQ==} engines: {node: '>=10'} + ora@9.4.0: + resolution: {integrity: sha512-84cglkRILFxdtA8hAvLNdMrtBpPNBTrQ9/ulg0FA7xLMnD6mifv+enAIeRmvtv+WgdCE+LPGOfQmtJRrVaIVhQ==} + engines: {node: '>=20'} + osc-progress@0.3.0: resolution: {integrity: sha512-4/8JfsetakdeEa4vAYV45FW20aY+B/+K8NEXp5Eiar3wR8726whgHrbSg5Ar/ZY1FLJ/AGtUqV7W2IVF+Gvp9A==} engines: {node: '>=20'} @@ -6255,6 +6314,10 @@ packages: resolution: {integrity: sha512-l+sSefzHpj5qimhFSE5a8nufZYAM3sBSVMAPtYkmC+4EH2anSGaEMXSD0izRQbu9nfyQ9y5JrVmp7E8oZrUjvA==} engines: {node: '>=8'} + restore-cursor@5.1.0: + resolution: {integrity: sha512-oMA2dcrw6u0YfxJQXm342bFKX/E4sG9rbTzO9ptUcR/e8A33cHuvStiYOwH7fszkZlZ1z/ta9AAoPk2F4qIOHA==} + engines: {node: '>=18'} + retry@0.12.0: resolution: {integrity: sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow==} engines: {node: '>= 4'} @@ -6558,6 +6621,10 @@ packages: std-env@4.1.0: resolution: {integrity: sha512-Rq7ybcX2RuC55r9oaPVEW7/xu3tj8u4GeBYHBWCychFtzMIr86A7e3PPEBPT37sHStKX3+TiX/Fr/ACmJLVlLQ==} + stdin-discarder@0.3.2: + resolution: {integrity: sha512-eCPu1qRxPVkl5605OTWF8Wz40b4Mf45NY5LQmVPQ599knfs5QhASUm9GbJ5BDMDOXgrnh0wyEdvzmL//YMlw0A==} + engines: {node: '>=18'} + streamroller@3.1.5: resolution: {integrity: sha512-KFxaM7XT+irxvdqSP1LGLgNWbYN7ay5owZ3r/8t77p+EtSUAfUgtl7be3xtqtOmGUl9K9YPO2ca8133RlTjvKw==} engines: {node: '>=8.0'} @@ -6570,6 +6637,10 @@ packages: resolution: {integrity: sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==} engines: {node: '>=12'} + string-width@8.2.0: + resolution: {integrity: sha512-6hJPQ8N0V0P3SNmP6h2J99RLuzrWz2gvT7VnK5tKvrNqJoyS9W4/Fb8mo31UiPvy00z7DQXkP2hnKBVav76thw==} + engines: {node: '>=20'} + string_decoder@1.1.1: resolution: {integrity: sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==} @@ -7291,6 +7362,12 @@ snapshots: - supports-color - zod + '@ark/schema@0.56.0': + dependencies: + '@ark/util': 0.56.0 + + '@ark/util@0.56.0': {} + '@asamuzakjp/css-color@5.1.11': dependencies: '@asamuzakjp/generational-cache': 1.0.1 @@ -9936,14 +10013,14 @@ snapshots: '@slack/logger@4.0.1': dependencies: - '@types/node': 25.5.2 + '@types/node': 25.6.0 '@slack/oauth@3.0.5': dependencies: '@slack/logger': 4.0.1 '@slack/web-api': 7.15.1 '@types/jsonwebtoken': 9.0.10 - '@types/node': 25.5.2 + '@types/node': 25.6.0 jsonwebtoken: 9.0.3 transitivePeerDependencies: - debug @@ -9952,7 +10029,7 @@ snapshots: dependencies: '@slack/logger': 4.0.1 '@slack/web-api': 7.15.1 - '@types/node': 25.5.2 + '@types/node': 25.6.0 '@types/ws': 8.18.1 eventemitter3: 5.0.4 ws: 8.20.0 @@ -9967,7 +10044,7 @@ snapshots: dependencies: '@slack/logger': 4.0.1 '@slack/types': 2.20.1 - '@types/node': 25.5.2 + '@types/node': 25.6.0 '@types/retry': 0.12.0 axios: 1.15.2 eventemitter3: 5.0.4 @@ -10531,7 +10608,7 @@ snapshots: '@types/body-parser@1.19.6': dependencies: '@types/connect': 3.4.38 - '@types/node': 25.5.2 + '@types/node': 25.6.0 '@types/bun@1.3.11': dependencies: @@ -10542,7 +10619,7 @@ snapshots: dependencies: '@types/http-cache-semantics': 4.2.0 '@types/keyv': 3.1.4 - '@types/node': 25.5.2 + '@types/node': 25.6.0 '@types/responselike': 1.0.3 '@types/chai@5.2.3': @@ -10556,7 +10633,7 @@ snapshots: '@types/connect@3.4.38': dependencies: - '@types/node': 25.5.2 + '@types/node': 25.6.0 '@types/crypto-js@4.2.2': {} @@ -10578,7 +10655,7 @@ snapshots: '@types/express-serve-static-core@5.1.1': dependencies: - '@types/node': 25.5.2 + '@types/node': 25.6.0 '@types/qs': 6.15.0 '@types/range-parser': 1.2.7 '@types/send': 1.2.1 @@ -10591,7 +10668,7 @@ snapshots: '@types/fs-extra@9.0.13': dependencies: - '@types/node': 25.5.2 + '@types/node': 25.6.0 '@types/hast@3.0.4': dependencies: @@ -10606,11 +10683,11 @@ snapshots: '@types/jsonwebtoken@9.0.10': dependencies: '@types/ms': 2.1.0 - '@types/node': 25.5.2 + '@types/node': 25.6.0 '@types/keyv@3.1.4': dependencies: - '@types/node': 25.5.2 + '@types/node': 25.6.0 '@types/lodash-es@4.17.12': dependencies: @@ -10650,7 +10727,7 @@ snapshots: '@types/plist@3.0.5': dependencies: - '@types/node': 25.5.2 + '@types/node': 25.6.0 xmlbuilder: 15.1.1 optional: true @@ -10668,18 +10745,18 @@ snapshots: '@types/responselike@1.0.3': dependencies: - '@types/node': 25.5.2 + '@types/node': 25.6.0 '@types/retry@0.12.0': {} '@types/send@1.2.1': dependencies: - '@types/node': 25.5.2 + '@types/node': 25.6.0 '@types/serve-static@2.2.0': dependencies: '@types/http-errors': 2.0.5 - '@types/node': 25.5.2 + '@types/node': 25.6.0 '@types/unist@2.0.11': {} @@ -10694,7 +10771,7 @@ snapshots: '@types/yauzl@2.10.3': dependencies: - '@types/node': 25.5.2 + '@types/node': 25.6.0 optional: true '@typescript-eslint/parser@5.62.0(typescript@5.9.3)': @@ -10836,7 +10913,7 @@ snapshots: '@cacheable/node-cache': 1.7.6 '@hapi/boom': 9.1.4 async-mutex: 0.5.0 - libsignal: '@whiskeysockets/libsignal-node@https://codeload.github.com/whiskeysockets/libsignal-node/tar.gz/1c30d7d7e76a3b0aa120b04dc6a26f5a12dccf67' + libsignal: '@whiskeysockets/libsignal-node@git+https://github.com/whiskeysockets/libsignal-node.git#1c30d7d7e76a3b0aa120b04dc6a26f5a12dccf67' lru-cache: 11.3.5 music-metadata: 11.12.3 p-queue: 9.1.2 @@ -10851,7 +10928,7 @@ snapshots: - supports-color - utf-8-validate - '@whiskeysockets/libsignal-node@https://codeload.github.com/whiskeysockets/libsignal-node/tar.gz/1c30d7d7e76a3b0aa120b04dc6a26f5a12dccf67': + '@whiskeysockets/libsignal-node@git+https://github.com/whiskeysockets/libsignal-node.git#1c30d7d7e76a3b0aa120b04dc6a26f5a12dccf67': dependencies: curve25519-js: 0.0.4 protobufjs: 6.8.8 @@ -11011,6 +11088,16 @@ snapshots: aria-query@5.3.2: {} + arkregex@0.0.5: + dependencies: + '@ark/util': 0.56.0 + + arktype@2.2.0: + dependencies: + '@ark/schema': 0.56.0 + '@ark/util': 0.56.0 + arkregex: 0.0.5 + array-back@3.1.0: {} array-back@6.2.3: {} @@ -11322,10 +11409,28 @@ snapshots: dependencies: clsx: 2.1.1 + clawhub@0.9.0: + dependencies: + '@clack/prompts': 1.2.0 + arktype: 2.2.0 + commander: 14.0.3 + fflate: 0.8.2 + ignore: 7.0.5 + json5: 2.2.3 + mime: 4.1.0 + ora: 9.4.0 + p-retry: 7.1.1 + semver: 7.7.4 + undici: 7.25.0 + cli-cursor@3.1.0: dependencies: restore-cursor: 3.1.0 + cli-cursor@5.0.0: + dependencies: + restore-cursor: 5.1.0 + cli-highlight@2.1.11: dependencies: chalk: 4.1.2 @@ -11337,6 +11442,8 @@ snapshots: cli-spinners@2.9.2: {} + cli-spinners@3.4.0: {} + cli-truncate@2.1.0: dependencies: slice-ansi: 3.0.0 @@ -12123,6 +12230,8 @@ snapshots: node-domexception: 1.0.0 web-streams-polyfill: 3.3.3 + fflate@0.8.2: {} + file-type@21.3.4: dependencies: '@tokenizer/inflate': 0.4.1 @@ -12713,6 +12822,8 @@ snapshots: is-interactive@1.0.0: {} + is-interactive@2.0.0: {} + is-network-error@1.3.1: {} is-number@7.0.0: {} @@ -12729,6 +12840,8 @@ snapshots: is-unicode-supported@0.1.0: {} + is-unicode-supported@2.1.0: {} + isarray@1.0.0: {} isbinaryfile@4.0.10: {} @@ -13013,6 +13126,11 @@ snapshots: chalk: 4.1.2 is-unicode-supported: 0.1.0 + log-symbols@7.0.1: + dependencies: + is-unicode-supported: 2.1.0 + yoctocolors: 2.1.2 + log4js@6.9.1: dependencies: date-format: 4.0.14 @@ -13518,6 +13636,8 @@ snapshots: mime@3.0.0: {} + mime@4.1.0: {} + mimic-fn@2.1.0: {} mimic-function@5.0.1: {} @@ -13600,7 +13720,7 @@ snapshots: mockjs@1.1.0: dependencies: - commander: 12.1.0 + commander: 14.0.3 motion-dom@12.38.0: dependencies: @@ -13817,6 +13937,10 @@ snapshots: dependencies: mimic-fn: 2.1.0 + onetime@7.0.0: + dependencies: + mimic-function: 5.0.1 + oniguruma-parser@0.12.2: {} oniguruma-to-es@4.3.6: @@ -13990,6 +14114,17 @@ snapshots: strip-ansi: 6.0.1 wcwidth: 1.0.1 + ora@9.4.0: + dependencies: + chalk: 5.6.2 + cli-cursor: 5.0.0 + cli-spinners: 3.4.0 + is-interactive: 2.0.0 + is-unicode-supported: 2.1.0 + log-symbols: 7.0.1 + stdin-discarder: 0.3.2 + string-width: 8.2.0 + osc-progress@0.3.0: {} p-cancelable@2.1.1: {} @@ -14282,7 +14417,7 @@ snapshots: '@protobufjs/path': 1.1.2 '@protobufjs/pool': 1.1.0 '@protobufjs/utf8': 1.1.0 - '@types/node': 25.5.2 + '@types/node': 25.6.0 long: 5.3.2 proxy-addr@2.0.7: @@ -14539,6 +14674,11 @@ snapshots: onetime: 5.1.2 signal-exit: 3.0.7 + restore-cursor@5.1.0: + dependencies: + onetime: 7.0.0 + signal-exit: 4.1.0 + retry@0.12.0: {} retry@0.13.1: {} @@ -14929,6 +15069,8 @@ snapshots: std-env@4.1.0: {} + stdin-discarder@0.3.2: {} + streamroller@3.1.5: dependencies: date-format: 4.0.14 @@ -14949,6 +15091,11 @@ snapshots: emoji-regex: 9.2.2 strip-ansi: 7.2.0 + string-width@8.2.0: + dependencies: + get-east-asian-width: 1.5.0 + strip-ansi: 7.2.0 + string_decoder@1.1.1: dependencies: safe-buffer: 5.1.2 diff --git a/src/i18n/locales/en/skills.json b/src/i18n/locales/en/skills.json index 5eee7e3..78b0bd6 100644 --- a/src/i18n/locales/en/skills.json +++ b/src/i18n/locales/en/skills.json @@ -90,6 +90,11 @@ "installDialogSubtitle": "Browse Explore by default, or enter keywords to search.", "sourceLabel": "Source", "sourceClawHub": "ClawHub", + "githubInstallLabel": "Install From GitHub", + "githubUrlPlaceholder": "Paste a GitHub /blob/.../SKILL.md or /tree/... skill URL", + "githubInstallAction": "Install URL", + "githubInstallHint": "Supports public github.com skill directories and installs them into \"{{path}}\".", + "githubUrlRequired": "Enter a GitHub skill URL first.", "securityNote": "Click skill card to view its documentation and security information on ClawHub before installation.", "manualInstallHint": "Network issues? You can always download skill ZIP archives from ClawHub.ai and extract them manually into \"{{path}}\".", "searching": "Searching ClawHub...", diff --git a/src/i18n/locales/th/skills.json b/src/i18n/locales/th/skills.json index da1b225..0726ffc 100644 --- a/src/i18n/locales/th/skills.json +++ b/src/i18n/locales/th/skills.json @@ -90,6 +90,11 @@ "installDialogSubtitle": "ค่าเริ่มต้นจะแสดง Explore และจะค้นหาเมื่อมีการกรอกคำสำคัญ", "sourceLabel": "แหล่งที่มา", "sourceClawHub": "ClawHub", + "githubInstallLabel": "ติดตั้งจาก GitHub", + "githubUrlPlaceholder": "วางลิงก์สกิล GitHub แบบ /blob/.../SKILL.md หรือ /tree/...", + "githubInstallAction": "ติดตั้งลิงก์", + "githubInstallHint": "รองรับ skill directory บน public github.com และจะติดตั้งไปที่ \"{{path}}\"", + "githubUrlRequired": "กรุณาใส่ลิงก์สกิล GitHub ก่อน", "securityNote": "ก่อนติดตั้ง ให้คลิกการ์ดสกิลเพื่อดูเอกสารและข้อมูลความปลอดภัยบน ClawHub", "manualInstallHint": "มีปัญหาเครือข่ายหรือไม่? คุณสามารถดาวน์โหลด ZIP ของสกิลจาก ClawHub.ai และแตกไฟล์ไปที่ \"{{path}}\" เพื่อติดตั้งด้วยตนเองได้ทุกเมื่อ", "searching": "กำลังค้นหา ClawHub...", diff --git a/src/i18n/locales/zh/skills.json b/src/i18n/locales/zh/skills.json index a81826d..64e8330 100644 --- a/src/i18n/locales/zh/skills.json +++ b/src/i18n/locales/zh/skills.json @@ -90,6 +90,11 @@ "installDialogSubtitle": "默认展示 Explore,也可以输入关键词搜索。", "sourceLabel": "来源", "sourceClawHub": "ClawHub", + "githubInstallLabel": "从 GitHub 安装", + "githubUrlPlaceholder": "粘贴 GitHub /blob/.../SKILL.md 或 /tree/... 技能链接", + "githubInstallAction": "安装链接", + "githubInstallHint": "支持 public github.com 的技能目录,并安装到 \"{{path}}\"。", + "githubUrlRequired": "请先输入 GitHub 技能链接。", "securityNote": "安装前请点击技能卡片,在 ClawHub 上查看其文档和安全信息。", "manualInstallHint": "遇到网络问题?您可以随时从 ClawHub.ai 下载技能压缩包,并将其解压至 \"{{path}}\" 目录来完成手动安装。", "searching": "正在搜索 ClawHub...", diff --git a/src/lib/skills-api.ts b/src/lib/skills-api.ts index 91feada..61879f1 100644 --- a/src/lib/skills-api.ts +++ b/src/lib/skills-api.ts @@ -48,6 +48,18 @@ type GatewaySkillsStatusResult = { skills?: GatewaySkillStatus[]; }; +export type SkillInstallRequest = + | { kind: 'marketplace'; slug: string; version?: string; force?: boolean } + | { kind: 'github-url'; url: string; force?: boolean }; + +export type SkillInstallResult = { + success: true; + slug: string; + baseDir: string; + source: 'marketplace' | 'github-url'; + enabled: true; +}; + function mapErrorCodeToSkillErrorKey( message: string, operation: 'fetch' | 'search' | 'install', @@ -245,16 +257,18 @@ export async function apiSearchSkills(query: string): Promise { +export async function apiInstallSkill(request: SkillInstallRequest): Promise { try { - const result = await hostApiFetch<{ success: boolean; error?: string }>('/api/clawhub/install', { + const result = await hostApiFetch('/api/skills/install', { method: 'POST', - body: JSON.stringify({ slug, version }), + body: JSON.stringify(request), }); if (!result?.success) { - throw new Error(result?.error || 'Install failed'); + throw new Error('Install failed'); } + + return result; } catch (error) { const message = error instanceof Error ? error.message : String(error); throw new Error(mapErrorCodeToSkillErrorKey(message, 'install')); @@ -320,11 +334,11 @@ export async function apiOpenSkillReadme(skillKey: string, slug?: string, baseDi export async function apiGetSkillsDir(): Promise { try { const result = await hostApiFetch<{ success: boolean; dir?: string; path?: string; error?: string }>('/api/clawhub/skills-dir'); - if (result?.success && (result.dir || result.path)) return result.dir || result.path || '~/.zn-ai/skills'; + if (result?.success && (result.dir || result.path)) return result.dir || result.path || '~/.openclaw/skills'; } catch { // Fallback to the default local path. } - return '~/.zn-ai/skills'; + return '~/.openclaw/skills'; } export async function apiGetGatewayStatus(): Promise<{ diff --git a/src/pages/Skills/components/MarketplaceDrawer.tsx b/src/pages/Skills/components/MarketplaceDrawer.tsx index 7016a89..488f6b1 100644 --- a/src/pages/Skills/components/MarketplaceDrawer.tsx +++ b/src/pages/Skills/components/MarketplaceDrawer.tsx @@ -18,9 +18,12 @@ type MarketplaceDrawerProps = { searchErrorLabel: string | null; installedSkills: Skill[]; installing: Record; + githubSkillUrl: string; skillsDirPath: string; t: SkillsTranslate; onClose: () => void; + onGithubUrlChange: (value: string) => void; + onInstallFromGithub: (url: string) => void | Promise; onQueryChange: (value: string) => void; onInstall: (slug: string) => void | Promise; onUninstall: (slug: string) => void | Promise; @@ -39,14 +42,19 @@ export default function MarketplaceDrawer({ searchErrorLabel, installedSkills, installing, + githubSkillUrl, skillsDirPath, t, onClose, + onGithubUrlChange, + onInstallFromGithub, onQueryChange, onInstall, onUninstall, onOpenExternal, }: MarketplaceDrawerProps) { + const githubInstalling = Boolean(installing['__github-url__']); + useEffect(() => { if (!open) return undefined; @@ -119,6 +127,52 @@ export default function MarketplaceDrawer({ {t('skills.marketplace.sourceLabel')}: {t('skills.marketplace.sourceClawHub')} + +
+
+ {t('skills.marketplace.githubInstallLabel', undefined, 'Install From GitHub')} +
+ +
+ onGithubUrlChange(event.target.value)} + onKeyDown={(event) => { + if (event.key === 'Enter' && githubSkillUrl.trim() && !githubInstalling) { + event.preventDefault(); + void onInstallFromGithub(githubSkillUrl); + } + }} + placeholder={t( + 'skills.marketplace.githubUrlPlaceholder', + undefined, + 'Paste a GitHub /blob/.../SKILL.md or /tree/... skill URL', + )} + className="h-10 flex-1 rounded-xl border border-black/10 bg-white px-3 text-[13px] text-[#171717] outline-none placeholder:text-[#171717]/45 dark:border-gray-700 dark:bg-[#222225] dark:text-[#f3f4f6] dark:placeholder:text-gray-500" + /> + + +
+ +

+ {t( + 'skills.marketplace.githubInstallHint', + { path: skillsDirPath }, + 'Supports public github.com skill directories and installs them into "{{path}}".', + )} +

+
diff --git a/src/pages/Skills/index.tsx b/src/pages/Skills/index.tsx index bacf22c..c7b83bf 100644 --- a/src/pages/Skills/index.tsx +++ b/src/pages/Skills/index.tsx @@ -34,6 +34,8 @@ type FeedbackTone = 'info' | 'success' | 'error'; const INSTALL_ERROR_CODES = new Set(['installTimeoutError', 'installRateLimitError']); const FETCH_ERROR_CODES = new Set(['fetchTimeoutError', 'fetchRateLimitError', 'timeoutError', 'rateLimitError']); const SEARCH_ERROR_CODES = new Set(['searchTimeoutError', 'searchRateLimitError', 'timeoutError', 'rateLimitError']); +const DEFAULT_SKILLS_DIR = '~/.openclaw/skills'; +const GITHUB_INSTALL_KEY = '__github-url__'; type FeedbackState = { id: number; @@ -56,8 +58,9 @@ export default function SkillsPage() { const [marketplaceResults, setMarketplaceResults] = useState([]); const [marketplaceSearching, setMarketplaceSearching] = useState(false); const [marketplaceSearchError, setMarketplaceSearchError] = useState(null); + const [githubSkillUrl, setGithubSkillUrl] = useState(''); const [installing, setInstalling] = useState>({}); - const [skillsDirPath, setSkillsDirPath] = useState('~/.zn-ai/skills'); + const [skillsDirPath, setSkillsDirPath] = useState(DEFAULT_SKILLS_DIR); const [feedback, setFeedback] = useState(null); const [isGatewayRunning, setIsGatewayRunning] = useState(false); const [showGatewayWarning, setShowGatewayWarning] = useState(false); @@ -196,9 +199,9 @@ export default function SkillsPage() { async function loadSkillsDir() { try { const dir = await apiGetSkillsDir(); - setSkillsDirPath(dir || '~/.zn-ai/skills'); + setSkillsDirPath(dir || DEFAULT_SKILLS_DIR); } catch { - setSkillsDirPath('~/.zn-ai/skills'); + setSkillsDirPath(DEFAULT_SKILLS_DIR); } } @@ -310,16 +313,44 @@ export default function SkillsPage() { return; } - setInstalling((currentInstalling) => ({ - ...currentInstalling, - [slug]: true, - })); + try { + const result = await runInstall( + slug, + { kind: 'marketplace', slug, version: marketplaceSkill.version }, + ); + if (result) { + setSelectedSkillId(result.slug); + } + } catch (caughtError) { + const message = caughtError instanceof Error ? caughtError.message : String(caughtError); + pushFeedback( + INSTALL_ERROR_CODES.has(message) + ? t(`skills.toast.${message}`, { path: skillsDirPath }, message) + : `${t('skills.toast.failedInstall')}: ${message}`, + 'error', + ); + } + } + + async function handleInstallFromGitHub(url: string) { + const trimmedUrl = url.trim(); + if (!trimmedUrl) { + pushFeedback( + t('skills.marketplace.githubUrlRequired', undefined, 'Enter a GitHub skill URL first.'), + 'error', + ); + return; + } try { - await apiInstallSkill(slug, marketplaceSkill.version); - await apiSetSkillEnabled(slug, true); - await loadSkills(); - pushFeedback(t('skills.toast.installed'), 'success'); + const result = await runInstall( + GITHUB_INSTALL_KEY, + { kind: 'github-url', url: trimmedUrl }, + ); + if (result) { + setGithubSkillUrl(''); + setSelectedSkillId(result.slug); + } } catch (caughtError) { const message = caughtError instanceof Error ? caughtError.message : String(caughtError); pushFeedback( @@ -328,12 +359,6 @@ export default function SkillsPage() { : `${t('skills.toast.failedInstall')}: ${message}`, 'error', ); - } finally { - setInstalling((currentInstalling) => { - const nextInstalling = { ...currentInstalling }; - delete nextInstalling[slug]; - return nextInstalling; - }); } } @@ -434,9 +459,34 @@ export default function SkillsPage() { setMarketplaceQuery(''); setMarketplaceResults([]); setMarketplaceSearchError(null); + setGithubSkillUrl(''); setMarketplaceOpen(true); } + async function runInstall( + installKey: string, + request: Parameters[0], + ) { + setInstalling((currentInstalling) => ({ + ...currentInstalling, + [installKey]: true, + })); + + try { + const result = await apiInstallSkill(request); + await apiSetSkillEnabled(result.slug, true); + await loadSkills(); + pushFeedback(t('skills.toast.installed'), 'success'); + return result; + } finally { + setInstalling((currentInstalling) => { + const nextInstalling = { ...currentInstalling }; + delete nextInstalling[installKey]; + return nextInstalling; + }); + } + } + return (
@@ -640,9 +690,12 @@ export default function SkillsPage() { : null} installedSkills={skills} installing={installing} + githubSkillUrl={githubSkillUrl} skillsDirPath={skillsDirPath} t={t} onClose={() => setMarketplaceOpen(false)} + onGithubUrlChange={setGithubSkillUrl} + onInstallFromGithub={handleInstallFromGitHub} onQueryChange={setMarketplaceQuery} onInstall={handleInstall} onUninstall={handleUninstall} diff --git a/tests/skill-install-service.test.ts b/tests/skill-install-service.test.ts new file mode 100644 index 0000000..a70d3c1 --- /dev/null +++ b/tests/skill-install-service.test.ts @@ -0,0 +1,363 @@ +// @vitest-environment node +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const mocks = vi.hoisted(() => { + const files = new Map(); + const dirs = new Set(['/']); + let tempCounter = 0; + + const normalize = (input: string): string => { + const raw = String(input || '').replace(/\\/g, '/'); + const absolute = raw.startsWith('/'); + const parts = raw.split('/').filter(Boolean); + const stack: string[] = []; + + for (const part of parts) { + if (part === '.') continue; + if (part === '..') { + if (stack.length > 0) { + stack.pop(); + } + continue; + } + stack.push(part); + } + + const normalized = `${absolute ? '/' : ''}${stack.join('/')}`; + return normalized || (absolute ? '/' : '.'); + }; + + const dirname = (input: string): string => { + const normalized = normalize(input); + if (normalized === '/' || normalized === '.') return normalized; + const parts = normalized.split('/').filter(Boolean); + parts.pop(); + return parts.length === 0 ? '/' : `/${parts.join('/')}`; + }; + + const basename = (input: string): string => { + const normalized = normalize(input); + if (normalized === '/' || normalized === '.') return normalized; + const parts = normalized.split('/').filter(Boolean); + return parts[parts.length - 1] || normalized; + }; + + const ensureDir = (input: string): string => { + const normalized = normalize(input); + const parts = normalized.split('/').filter(Boolean); + let current = normalized.startsWith('/') ? '/' : ''; + + if (normalized === '/' || normalized === '.') { + dirs.add(normalized); + return normalized; + } + + for (const part of parts) { + current = current === '/' ? `/${part}` : `${current}/${part}`; + dirs.add(current); + } + + return normalized; + }; + + const writeFile = (input: string, content: string): void => { + const normalized = normalize(input); + ensureDir(dirname(normalized)); + files.set(normalized, content); + }; + + const existsSync = vi.fn((input: string) => { + const normalized = normalize(input); + return dirs.has(normalized) || files.has(normalized); + }); + + const mkdir = vi.fn(async (input: string) => { + ensureDir(input); + }); + + const mkdtemp = vi.fn(async (prefix: string) => { + tempCounter += 1; + const dir = normalize(`${prefix}${tempCounter}`); + ensureDir(dir); + return dir; + }); + + const readFile = vi.fn(async (input: string) => { + const normalized = normalize(input); + const value = files.get(normalized); + if (typeof value !== 'string') { + throw new Error(`ENOENT: ${normalized}`); + } + return value; + }); + + const rm = vi.fn(async (input: string) => { + const normalized = normalize(input); + + for (const key of [...files.keys()]) { + if (key === normalized || key.startsWith(`${normalized}/`)) { + files.delete(key); + } + } + + for (const key of [...dirs]) { + if (key === normalized || key.startsWith(`${normalized}/`)) { + dirs.delete(key); + } + } + + dirs.add('/'); + }); + + const cp = vi.fn(async (source: string, destination: string) => { + const src = normalize(source); + const dest = normalize(destination); + ensureDir(dest); + + for (const dir of [...dirs]) { + if (dir === src || dir.startsWith(`${src}/`)) { + const relative = dir === src ? '' : dir.slice(src.length + 1); + ensureDir(relative ? `${dest}/${relative}` : dest); + } + } + + for (const [filePath, content] of [...files.entries()]) { + if (filePath === src || filePath.startsWith(`${src}/`)) { + const relative = filePath === src ? basename(filePath) : filePath.slice(src.length + 1); + writeFile(relative ? `${dest}/${relative}` : dest, content); + } + } + }); + + const reset = () => { + files.clear(); + dirs.clear(); + dirs.add('/'); + tempCounter = 0; + existsSync.mockClear(); + mkdir.mockClear(); + mkdtemp.mockClear(); + readFile.mockClear(); + rm.mockClear(); + cp.mockClear(); + }; + + return { + files, + dirs, + normalize, + dirname, + basename, + ensureDir, + writeFile, + existsSync, + mkdir, + mkdtemp, + readFile, + rm, + cp, + reset, + }; +}); + +vi.mock('node:fs', () => ({ + createWriteStream: vi.fn(() => ({ on: vi.fn() })), + existsSync: mocks.existsSync, +})); + +vi.mock('node:fs/promises', () => ({ + cp: mocks.cp, + mkdir: mocks.mkdir, + mkdtemp: mocks.mkdtemp, + readFile: mocks.readFile, + readdir: vi.fn(async () => []), + rm: mocks.rm, +})); + +vi.mock('node:path', () => ({ + basename: mocks.basename, + dirname: mocks.dirname, + join: (...parts: string[]) => mocks.normalize(parts.join('/')), + resolve: (...parts: string[]) => mocks.normalize(`/${parts.join('/')}`), + sep: '/', +})); + +vi.mock('node:os', () => ({ + tmpdir: () => '/tmp', +})); + +vi.mock('node:child_process', () => ({ + spawn: vi.fn(), +})); + +vi.mock('node:stream/promises', () => ({ + pipeline: vi.fn(async () => undefined), +})); + +vi.mock('axios', () => ({ + default: { + get: vi.fn(), + }, +})); + +vi.mock('extract-zip', () => ({ + default: vi.fn(async () => undefined), +})); + +vi.mock('@electron/utils/skill-config', () => ({ + updateSkillConfig: vi.fn(async () => ({ success: true })), +})); + +vi.mock('@electron/utils/paths', () => ({ + ensureDir: (input: string) => mocks.ensureDir(input), + getOpenClawConfigDir: () => '/openclaw', +})); + +import { + SkillInstallService, + SkillInstallServiceError, + parseGitHubSkillUrl, +} from '../electron/service/skill-install-service'; + +describe('SkillInstallService', () => { + beforeEach(() => { + mocks.reset(); + }); + + it('parses GitHub blob and tree skill URLs', () => { + expect(parseGitHubSkillUrl('https://github.com/MiniMax-AI/skills/blob/main/skills/minimax-xlsx/SKILL.md')).toMatchObject({ + owner: 'MiniMax-AI', + repo: 'skills', + ref: 'main', + skillPath: 'skills/minimax-xlsx', + defaultSlug: 'minimax-xlsx', + }); + + expect(parseGitHubSkillUrl('https://github.com/MiniMax-AI/skills/tree/main/skills/minimax-xlsx')).toMatchObject({ + owner: 'MiniMax-AI', + repo: 'skills', + ref: 'main', + skillPath: 'skills/minimax-xlsx', + defaultSlug: 'minimax-xlsx', + }); + }); + + it('installs marketplace skills and enables them', async () => { + const enableSkill = vi.fn(async () => undefined); + const install = vi.fn(async ({ slug }: { slug: string }) => { + mocks.writeFile(`/workspace/skills/${slug}/SKILL.md`, '---\nname: demo-skill\n---\n'); + }); + + const service = new SkillInstallService({ + clawHubService: { install } as any, + configDir: '/workspace', + skillsRootDir: '/workspace/skills', + enableSkill, + }); + + const result = await service.install({ kind: 'marketplace', slug: 'demo-skill', version: '1.2.3' }); + + expect(install).toHaveBeenCalledWith({ + slug: 'demo-skill', + version: '1.2.3', + force: undefined, + }); + expect(enableSkill).toHaveBeenCalledWith('demo-skill'); + expect(result).toMatchObject({ + success: true, + slug: 'demo-skill', + source: 'marketplace', + enabled: true, + }); + }); + + it('installs GitHub skill directories, prefers frontmatter name, and enables them', async () => { + const enableSkill = vi.fn(async () => undefined); + mocks.writeFile('/workspace/source-skill/SKILL.md', '---\nname: minimax-xlsx\ndescription: Spreadsheet helper\n---\n'); + + const service = new SkillInstallService({ + clawHubService: { install: vi.fn() } as any, + configDir: '/workspace', + skillsRootDir: '/workspace/skills', + enableSkill, + createTempDir: async () => '/workspace/temp-1', + resolveGitHubSkillDirectory: async () => '/workspace/source-skill', + }); + + const result = await service.install({ + kind: 'github-url', + url: 'https://github.com/MiniMax-AI/skills/blob/main/skills/minimax-xlsx/SKILL.md', + }); + + expect(result).toMatchObject({ + success: true, + slug: 'minimax-xlsx', + source: 'github-url', + enabled: true, + }); + expect(enableSkill).toHaveBeenCalledWith('minimax-xlsx'); + expect(mocks.files.get('/workspace/skills/minimax-xlsx/SKILL.md')).toContain('Spreadsheet helper'); + }); + + it('rejects GitHub installs when SKILL.md is missing', async () => { + mocks.writeFile('/workspace/source-skill/README.md', '# not a skill\n'); + + const service = new SkillInstallService({ + clawHubService: { install: vi.fn() } as any, + configDir: '/workspace', + skillsRootDir: '/workspace/skills', + enableSkill: async () => undefined, + createTempDir: async () => '/workspace/temp-1', + resolveGitHubSkillDirectory: async () => '/workspace/source-skill', + }); + + await expect(service.install({ + kind: 'github-url', + url: 'https://github.com/MiniMax-AI/skills/tree/main/skills/missing-skill', + })).rejects.toThrow('SKILL.md not found'); + }); + + it('rejects GitHub installs when the target directory already exists and force is false', async () => { + mocks.writeFile('/workspace/source-skill/SKILL.md', '---\nname: minimax-xlsx\n---\n'); + mocks.writeFile('/workspace/skills/minimax-xlsx/SKILL.md', '---\nname: minimax-xlsx\n---\n'); + + const service = new SkillInstallService({ + clawHubService: { install: vi.fn() } as any, + configDir: '/workspace', + skillsRootDir: '/workspace/skills', + enableSkill: async () => undefined, + createTempDir: async () => '/workspace/temp-1', + resolveGitHubSkillDirectory: async () => '/workspace/source-skill', + }); + + await expect(service.install({ + kind: 'github-url', + url: 'https://github.com/MiniMax-AI/skills/blob/main/skills/minimax-xlsx/SKILL.md', + })).rejects.toMatchObject>({ + status: 409, + code: 'skill_exists', + }); + }); + + it('overwrites GitHub installs when force is true', async () => { + mocks.writeFile('/workspace/source-skill/SKILL.md', '---\nname: minimax-xlsx\n---\nnew-content'); + mocks.writeFile('/workspace/skills/minimax-xlsx/SKILL.md', '---\nname: minimax-xlsx\n---\nold-content'); + + const service = new SkillInstallService({ + clawHubService: { install: vi.fn() } as any, + configDir: '/workspace', + skillsRootDir: '/workspace/skills', + enableSkill: async () => undefined, + createTempDir: async () => '/workspace/temp-1', + resolveGitHubSkillDirectory: async () => '/workspace/source-skill', + }); + + await service.install({ + kind: 'github-url', + url: 'https://github.com/MiniMax-AI/skills/blob/main/skills/minimax-xlsx/SKILL.md', + force: true, + }); + + expect(mocks.files.get('/workspace/skills/minimax-xlsx/SKILL.md')).toContain('new-content'); + }); +}); diff --git a/tests/skills-api.test.ts b/tests/skills-api.test.ts new file mode 100644 index 0000000..20ad56d --- /dev/null +++ b/tests/skills-api.test.ts @@ -0,0 +1,53 @@ +// @vitest-environment node +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const mocks = vi.hoisted(() => ({ + hostApiFetch: vi.fn(), + gatewayRpc: vi.fn(), +})); + +vi.mock('../src/lib/host-api', () => ({ + hostApiFetch: mocks.hostApiFetch, +})); + +vi.mock('../src/lib/gateway-client', () => ({ + gatewayRpc: mocks.gatewayRpc, +})); + +import { apiGetSkillsDir, apiInstallSkill } from '../src/lib/skills-api'; + +describe('skills api helpers', () => { + beforeEach(() => { + mocks.hostApiFetch.mockReset(); + mocks.gatewayRpc.mockReset(); + }); + + it('falls back to ~/.openclaw/skills when the host path request fails', async () => { + mocks.hostApiFetch.mockRejectedValue(new Error('offline')); + + await expect(apiGetSkillsDir()).resolves.toBe('~/.openclaw/skills'); + }); + + it('posts unified install requests to /api/skills/install', async () => { + mocks.hostApiFetch.mockResolvedValue({ + success: true, + slug: 'minimax-xlsx', + baseDir: 'C:/Users/Administrator/.openclaw/skills/minimax-xlsx', + source: 'github-url', + enabled: true, + }); + + await expect(apiInstallSkill({ + kind: 'github-url', + url: 'https://github.com/MiniMax-AI/skills/blob/main/skills/minimax-xlsx/SKILL.md', + })).resolves.toMatchObject({ + success: true, + slug: 'minimax-xlsx', + source: 'github-url', + }); + + expect(mocks.hostApiFetch).toHaveBeenCalledWith('/api/skills/install', expect.objectContaining({ + method: 'POST', + })); + }); +}); diff --git a/tests/skills-routes.test.ts b/tests/skills-routes.test.ts new file mode 100644 index 0000000..87ce675 --- /dev/null +++ b/tests/skills-routes.test.ts @@ -0,0 +1,152 @@ +// @vitest-environment node +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const mocks = vi.hoisted(() => ({ + install: vi.fn(), + getAllSkillConfigs: vi.fn(), + updateSkillConfig: vi.fn(), + openPath: vi.fn(), + MockSkillInstallServiceError: class MockSkillInstallServiceError extends Error { + status: number; + code: string; + + constructor(message: string, status: number, code: string) { + super(message); + this.status = status; + this.code = code; + } + }, +})); + +vi.mock('@service/skill-install-service', () => ({ + SkillInstallService: class { + install = mocks.install; + }, + SkillInstallServiceError: mocks.MockSkillInstallServiceError, +})); + +vi.mock('../electron/utils/skill-config', () => ({ + getAllSkillConfigs: mocks.getAllSkillConfigs, + updateSkillConfig: mocks.updateSkillConfig, +})); + +vi.mock('../electron/utils/paths', () => ({ + getOpenClawConfigDir: () => 'C:/Users/Administrator/.openclaw', +})); + +vi.mock('electron', () => ({ + shell: { + openPath: mocks.openPath, + }, +})); + +import { normalizeRequest } from '../electron/api/route-utils'; +import { handleSkillRoutes } from '../electron/api/routes/skills'; + +const ctx = { + gatewayManager: null, + providerApiService: null, + mainWindow: null, + clawHubService: { + getMarketplaceCapability: vi.fn(), + search: vi.fn(), + install: vi.fn(), + uninstall: vi.fn(), + listInstalled: vi.fn(), + openSkillReadme: vi.fn(), + openSkillPath: vi.fn(), + }, +} as any; + +describe('skill install routes', () => { + beforeEach(() => { + mocks.install.mockReset(); + mocks.getAllSkillConfigs.mockReset(); + mocks.updateSkillConfig.mockReset(); + mocks.openPath.mockReset(); + }); + + it('keeps /api/clawhub/install compatible with the marketplace payload', async () => { + mocks.install.mockResolvedValue({ + success: true, + slug: 'demo-skill', + baseDir: 'C:/Users/Administrator/.openclaw/skills/demo-skill', + source: 'marketplace', + enabled: true, + }); + + const response = await handleSkillRoutes(normalizeRequest({ + path: '/api/clawhub/install', + method: 'POST', + body: JSON.stringify({ + slug: 'demo-skill', + version: '1.2.3', + force: true, + }), + }), ctx); + + expect(mocks.install).toHaveBeenCalledWith({ + kind: 'marketplace', + slug: 'demo-skill', + version: '1.2.3', + force: true, + }); + expect(response?.ok).toBe(true); + expect(response?.json).toMatchObject({ + success: true, + slug: 'demo-skill', + source: 'marketplace', + }); + }); + + it('handles /api/skills/install for github-url payloads', async () => { + mocks.install.mockResolvedValue({ + success: true, + slug: 'minimax-xlsx', + baseDir: 'C:/Users/Administrator/.openclaw/skills/minimax-xlsx', + source: 'github-url', + enabled: true, + }); + + const response = await handleSkillRoutes(normalizeRequest({ + path: '/api/skills/install', + method: 'POST', + body: JSON.stringify({ + kind: 'github-url', + url: 'https://github.com/MiniMax-AI/skills/blob/main/skills/minimax-xlsx/SKILL.md', + }), + }), ctx); + + expect(mocks.install).toHaveBeenCalledWith({ + kind: 'github-url', + url: 'https://github.com/MiniMax-AI/skills/blob/main/skills/minimax-xlsx/SKILL.md', + }); + expect(response?.ok).toBe(true); + expect(response?.json).toMatchObject({ + success: true, + slug: 'minimax-xlsx', + source: 'github-url', + }); + }); + + it('returns the service status code when install validation fails', async () => { + mocks.install.mockRejectedValue(new mocks.MockSkillInstallServiceError( + 'GitHub skill URL is invalid.', + 400, + 'invalid_github_url', + )); + + const response = await handleSkillRoutes(normalizeRequest({ + path: '/api/skills/install', + method: 'POST', + body: JSON.stringify({ + kind: 'github-url', + url: 'https://example.com/not-supported', + }), + }), ctx); + + expect(response?.ok).toBe(false); + expect(response?.status).toBe(400); + expect(response?.error).toBe('GitHub skill URL is invalid.'); + }); +});