@nnnggel/skills-management 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +112 -0
- package/README.zh-CN.md +112 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +806 -0
- package/package.json +44 -0
package/README.md
ADDED
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
# Skills Management CLI (skm)
|
|
2
|
+
|
|
3
|
+
[简体中文](./README.zh-CN.md) | English
|
|
4
|
+
|
|
5
|
+
`skm` is a powerful CLI tool designed to manage and synchronize "skills" (prompt libraries, instruction sets, or capability modules) across various AI coding agents and projects. It serves as a central hub to download skills from GitHub and selectively link them into your local AI project configurations.
|
|
6
|
+
|
|
7
|
+
It supports multiple AI environments including **OpenCode**, **Cursor**, **Gemini**, **Antigravity**, **Claude**, and **GitHub** projects.
|
|
8
|
+
|
|
9
|
+
---
|
|
10
|
+
|
|
11
|
+
## 🚀 Features
|
|
12
|
+
|
|
13
|
+
- **Global Skill Repository**: Centralized management of your AI skills.
|
|
14
|
+
- **GitHub Integration**: Add skills directly from GitHub repositories or specific subdirectories (sparse checkout).
|
|
15
|
+
- **Version Control**: Check for updates and synchronize changes from remote repositories.
|
|
16
|
+
- **Project Detection**: Automatically detects the AI project type in your current directory.
|
|
17
|
+
- **Symbolic Linking**: Efficiently links skills to projects without duplication, keeping them in sync.
|
|
18
|
+
- **Project Isolation**: Manage different sets of skills for different projects.
|
|
19
|
+
|
|
20
|
+
## 💡 Why skm?
|
|
21
|
+
|
|
22
|
+
- **Centralized Efficiency**: Skills are cloned only once (`~/.skills-management/repo`) and shared across infinite projects via symbolic links. This saves significant disk space and ensures all your projects use the same curated version of a skill.
|
|
23
|
+
- **Precision (Sparse Checkout)**: Only download what you need. If a GitHub repository contains hundreds of skills but you only want one, `skm` uses Git's sparse-checkout to download only that specific subdirectory. No more cloning massive repositories for a single prompt file.
|
|
24
|
+
- **Automatic Environment Awareness**: You don't need to know where Cursor, Claude, or OpenCode stores their skills. `skm` automatically detects your project environment and handles the directory structures for you.
|
|
25
|
+
- **Clean & Non-Intrusive**: Since skills are symlinked, your project folder stays clean. No extra `.git` folders or heavy files are added to your project's version control.
|
|
26
|
+
- **Self-healing**: Proactively detects and helps you clean up broken symbolic links if a global skill is deleted.
|
|
27
|
+
- **Version Sync**: One-click check for updates across all your global skills to keep your local "AI brain" sharp.
|
|
28
|
+
|
|
29
|
+
## 📦 Installation
|
|
30
|
+
|
|
31
|
+
This tool can be installed directly from npm:
|
|
32
|
+
|
|
33
|
+
```bash
|
|
34
|
+
npm install -g skills-management
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
Or install from source:
|
|
38
|
+
|
|
39
|
+
```bash
|
|
40
|
+
# Clone the repository
|
|
41
|
+
git clone https://github.com/nnnggel/skills-management.git
|
|
42
|
+
cd skills-management
|
|
43
|
+
npm install
|
|
44
|
+
npm run build
|
|
45
|
+
npm link
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
## 📖 Usage
|
|
49
|
+
|
|
50
|
+
Run the tool using `skm`:
|
|
51
|
+
|
|
52
|
+
```bash
|
|
53
|
+
skm
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
### 1. Global Repository Management (`repo`)
|
|
57
|
+
|
|
58
|
+
Select **"1. repo"** from the main menu to manage your global collection of skills.
|
|
59
|
+
|
|
60
|
+
- **Add Skill**: Enter a GitHub URL to download a skill.
|
|
61
|
+
- Supports full repositories: `https://github.com/user/repo`
|
|
62
|
+
- Supports specific subfolders: `https://github.com/user/repo/tree/main/path/to/skill`
|
|
63
|
+
- **Update Skills**: Checks for newer commits on the remote GitHub repository and updates your local copy.
|
|
64
|
+
- **Delete Skills**: Remove skills from your global repository.
|
|
65
|
+
|
|
66
|
+
### 2. Project Skill Management (`list`)
|
|
67
|
+
|
|
68
|
+
Navigate to your AI project directory and run `skm`. The tool will detect the project type (e.g., `.opencode`, `.cursor`).
|
|
69
|
+
|
|
70
|
+
Select **"2. list(type)"** to manage skills for the current project.
|
|
71
|
+
|
|
72
|
+
- **Link/Unlink**: You will see a list of available global skills.
|
|
73
|
+
- **Checkbox Interface**: Use `Space` to select or deselect skills, and `Enter` to confirm.
|
|
74
|
+
- **Symlinks**: Selected skills are symlinked contents from your global repo into your project's skill directory (e.g., `.opencode/skills/`).
|
|
75
|
+
|
|
76
|
+
### Supported Project Structures
|
|
77
|
+
|
|
78
|
+
`skm` automatically detects and installs skills into these directories:
|
|
79
|
+
|
|
80
|
+
| AI Type | Detected Folder | Skills Installation Path |
|
|
81
|
+
|---------|-----------------|--------------------------|
|
|
82
|
+
| **OpenCode** | `.opencode` | `.opencode/skills` |
|
|
83
|
+
| **Cursor** | `.cursor` | `.cursor/skills` |
|
|
84
|
+
| **Gemini** | `.gemini` | `.gemini/skills` |
|
|
85
|
+
| **Antigravity** | `.antigravity` | `.antigravity/skills` |
|
|
86
|
+
| **Claude** | `.claude` | `.claude/skills` |
|
|
87
|
+
| **GitHub** | `.github` | `.github/skills` |
|
|
88
|
+
|
|
89
|
+
---
|
|
90
|
+
|
|
91
|
+
## 🏗️ Directory Structure & Internals
|
|
92
|
+
|
|
93
|
+
`skm` stores its global data and configuration in your home directory at `~/.skills-management`. This architecture ensures that skills are downloaded once and shared across multiple projects.
|
|
94
|
+
|
|
95
|
+
### Data Layout:
|
|
96
|
+
|
|
97
|
+
- **`config.json`**: Global configuration file. It stores system-level settings and metadata.
|
|
98
|
+
- **`repo/`**: The heart of the management system.
|
|
99
|
+
- **`versions.json`**: The skill registry. It tracks all added skills, their unique IDs, current commit hashes (for version control), and original paths.
|
|
100
|
+
- **`github__[user]__[repo]__[subpath]/`**: Local Git repositories. `skm` flattens the ID (replacing `/` with `__`) to create safe directory names.
|
|
101
|
+
- **Note**: `skm` handles complex paths like `github:user/repo/path/to/skill` by creating a unique folder like `github__user__repo__path__to__skill`.
|
|
102
|
+
|
|
103
|
+
### Cross-Platform Compatibility (Windows)
|
|
104
|
+
`skm` is fully compatible with Windows.
|
|
105
|
+
- On **macOS/Linux**, it uses standard symbolic links.
|
|
106
|
+
- On **Windows**, it automatically uses **Directory Junctions** (a type of symbolic link for folders) to ensure capability without requiring Administrator privileges or Developer Mode.
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
## 📄 License
|
|
111
|
+
|
|
112
|
+
ISC
|
package/README.zh-CN.md
ADDED
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
# Skills Management CLI (skm)
|
|
2
|
+
|
|
3
|
+
简体中文 | [English](./README.md)
|
|
4
|
+
|
|
5
|
+
`skm` 是一个强大的命令行工具,用于在各种 AI 编程代理和项目中管理与同步“技能”(Skills,即提示词库、指令集或能力模块)。它作为一个中心枢纽,帮助你从 GitHub 下载技能,并将其选择性地链接到本地 AI 项目配置中。
|
|
6
|
+
|
|
7
|
+
支持多种 AI 环境,包括 **OpenCode**、**Cursor**、**Gemini**、**Antigravity**、**Claude** 和 **GitHub** 项目。
|
|
8
|
+
|
|
9
|
+
---
|
|
10
|
+
|
|
11
|
+
## 🚀 功能特性
|
|
12
|
+
|
|
13
|
+
- **全局技能仓库**:集中管理所有的 AI 技能。
|
|
14
|
+
- **GitHub 集成**:直接从 GitHub 仓库或指定子目录添加技能(支持稀疏检出)。
|
|
15
|
+
- **版本控制**:检查远程更新并同步技能版本。
|
|
16
|
+
- **项目检测**:自动检测当前目录下的 AI 项目类型。
|
|
17
|
+
- **软链接管理**:通过符号链接将技能高效注入项目,避免文件复制,保持同步。
|
|
18
|
+
- **项目隔离**:为不同项目配置不同的技能组合。
|
|
19
|
+
|
|
20
|
+
## 💡 为什么选择 skm?
|
|
21
|
+
|
|
22
|
+
- **核心效率**:技能仅克隆一次(存储在 `~/.skills-management/repo`),通过符号链接在无限个项目间共享。这不仅极大地节省了磁盘空间,还确保了所有项目使用的技能版本高度统一。
|
|
23
|
+
- **精准下载 (稀疏检出)**:只下载你需要的。如果一个 GitHub 仓库包含几百个技能,而你只需要其中一个,`skm` 会利用 Git 的 `sparse-checkout` 特性仅下载特定的子目录。告别为了一个几 KB 的 Prompt 而克隆整个几百 MB 仓库的时代。
|
|
24
|
+
- **自动化环境感知**:你不需要记住 Cursor、Claude 或 OpenCode 把技能存在哪里。`skm` 会自动识别你的项目环境并处理对应的目录结构。
|
|
25
|
+
- **整洁且无侵入**:由于使用的是软链接,你的项目文件夹保持纯净。不会有额外的 `.git` 文件夹或庞大的二进制文件进入你的项目版本控制。
|
|
26
|
+
- **自我维护**:主动检测并帮助你清理失效的软链接(例如当全局技能被删除时)。
|
|
27
|
+
- **版本同步**:一键检查所有全局技能的远程更新,确保你的“AI 大脑”始终处于最新状态。
|
|
28
|
+
|
|
29
|
+
## 📦 安装指南
|
|
30
|
+
|
|
31
|
+
可以直接从 npm 安装:
|
|
32
|
+
|
|
33
|
+
```bash
|
|
34
|
+
npm install -g skills-management
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
或者从源码安装:
|
|
38
|
+
|
|
39
|
+
```bash
|
|
40
|
+
# 克隆仓库
|
|
41
|
+
git clone https://github.com/nnnggel/skills-management.git
|
|
42
|
+
cd skills-management
|
|
43
|
+
|
|
44
|
+
# 安装依赖并构建
|
|
45
|
+
npm install
|
|
46
|
+
npm run build
|
|
47
|
+
npm link
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
## 📖 使用指南
|
|
51
|
+
|
|
52
|
+
直接运行命令 `skm`:
|
|
53
|
+
|
|
54
|
+
```bash
|
|
55
|
+
skm
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
### 1. 全局仓库管理 (`repo`)
|
|
59
|
+
|
|
60
|
+
在主菜单选择 **"1. repo"** 来管理你的全局技能库。
|
|
61
|
+
|
|
62
|
+
- **添加技能 (Add skill)**:输入 GitHub URL 下载技能。
|
|
63
|
+
- 支持完整仓库:`https://github.com/user/repo`
|
|
64
|
+
- 支持特定子目录:`https://github.com/user/repo/tree/main/path/to/skill`
|
|
65
|
+
- **更新技能 (Update)**:自动检查远程仓库是否有新提交,并更新本地副本。
|
|
66
|
+
- **删除技能 (Delete)**:从全局仓库中移除技能。
|
|
67
|
+
|
|
68
|
+
### 2. 项目技能管理 (`list`)
|
|
69
|
+
|
|
70
|
+
在你的 AI 项目根目录下运行 `skm`。工具会自动识别项目类型(如 `.opencode`, `.cursor` 等)。
|
|
71
|
+
|
|
72
|
+
选择 **"2. list(type)"** 来管理当前项目的技能。
|
|
73
|
+
|
|
74
|
+
- **链接/取消链接 (Link/Unlink)**:显示全局可用技能列表。
|
|
75
|
+
- **复选框交互**:使用 `空格键` 选中或取消选中技能,`回车键` 确认。
|
|
76
|
+
- **自动注入**:选中的技能会以软链接形式注入到项目的技能目录中(例如 `.opencode/skills/`)。
|
|
77
|
+
|
|
78
|
+
### 支持的项目结构
|
|
79
|
+
|
|
80
|
+
`skm` 自动识别以下目录结构并安装技能:
|
|
81
|
+
|
|
82
|
+
| AI 类型 | 识别目录 | 技能安装路径 |
|
|
83
|
+
|---------|-----------------|--------------------------|
|
|
84
|
+
| **OpenCode** | `.opencode` | `.opencode/skills` |
|
|
85
|
+
| **Cursor** | `.cursor` | `.cursor/skills` |
|
|
86
|
+
| **Gemini** | `.gemini` | `.gemini/skills` |
|
|
87
|
+
| **Antigravity** | `.antigravity` | `.antigravity/skills` |
|
|
88
|
+
| **Claude** | `.claude` | `.claude/skills` |
|
|
89
|
+
| **GitHub** | `.github` | `.github/skills` |
|
|
90
|
+
|
|
91
|
+
---
|
|
92
|
+
|
|
93
|
+
## 🏗️ 目录结构与运行机制
|
|
94
|
+
|
|
95
|
+
`skm` 将所有的全局数据和配置存储在用户主目录下的 `~/.skills-management` 文件夹中。这种设计确保了技能只需下载一次,即可在多个项目间共享。
|
|
96
|
+
|
|
97
|
+
### 内部结构:
|
|
98
|
+
|
|
99
|
+
- **`config.json`**: 全局配置文件,存储系统级设置和元数据。
|
|
100
|
+
- **`repo/`**: 管理系统的核心目录。
|
|
101
|
+
- **`versions.json`**: 技能注册表(数据库)。记录了所有已添加技能的 ID、当前 Commit Hash(用于版本追踪)、检出类型和路径信息。
|
|
102
|
+
- **`github__[user]__[repo]__[subpath]/`**: 本地 Git 仓库存储中心。`skm` 会将 ID 扁平化(将 `/` 替换为 `__`)以创建安全的文件目录名。例如 `github:user/repo/path` 会被存储为 `github__user__repo__path`。
|
|
103
|
+
|
|
104
|
+
### Windows 兼容性说明
|
|
105
|
+
`skm` 完全兼容 Windows 系统。
|
|
106
|
+
- 在 **macOS/Linux** 上,使用标准的符号链接 (Symbolic Links)。
|
|
107
|
+
- 在 **Windows** 上,工具会自动使用 **Directory Junctions** (目录联接点)。这是 Windows NTFS 文件系统的一种特性,类似于目录的软链接,但通常**不需要管理员权限**即可创建,确保了最佳的开箱即用体验。
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
## 📄 License
|
|
111
|
+
|
|
112
|
+
ISC
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,806 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
"use strict";
|
|
3
|
+
var __create = Object.create;
|
|
4
|
+
var __defProp = Object.defineProperty;
|
|
5
|
+
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
|
|
6
|
+
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
7
|
+
var __getProtoOf = Object.getPrototypeOf;
|
|
8
|
+
var __hasOwnProp = Object.prototype.hasOwnProperty;
|
|
9
|
+
var __copyProps = (to, from, except, desc) => {
|
|
10
|
+
if (from && typeof from === "object" || typeof from === "function") {
|
|
11
|
+
for (let key of __getOwnPropNames(from))
|
|
12
|
+
if (!__hasOwnProp.call(to, key) && key !== except)
|
|
13
|
+
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
|
|
14
|
+
}
|
|
15
|
+
return to;
|
|
16
|
+
};
|
|
17
|
+
var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps(
|
|
18
|
+
// If the importer is in node compatibility mode or this is not an ESM
|
|
19
|
+
// file that has been converted to a CommonJS file using a Babel-
|
|
20
|
+
// compatible transform (i.e. "__esModule" has not been set), then set
|
|
21
|
+
// "default" to the CommonJS "module.exports" for node compatibility.
|
|
22
|
+
isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target,
|
|
23
|
+
mod
|
|
24
|
+
));
|
|
25
|
+
|
|
26
|
+
// src/index.ts
|
|
27
|
+
var import_fs_extra7 = __toESM(require("fs-extra"));
|
|
28
|
+
var import_inquirer3 = __toESM(require("inquirer"));
|
|
29
|
+
|
|
30
|
+
// src/core/config.ts
|
|
31
|
+
var import_os = __toESM(require("os"));
|
|
32
|
+
var import_path = __toESM(require("path"));
|
|
33
|
+
var import_fs_extra = __toESM(require("fs-extra"));
|
|
34
|
+
var ConfigManager = class {
|
|
35
|
+
homeDir;
|
|
36
|
+
repoDir;
|
|
37
|
+
configFile;
|
|
38
|
+
constructor() {
|
|
39
|
+
this.homeDir = import_path.default.join(import_os.default.homedir(), ".skills-management");
|
|
40
|
+
this.repoDir = import_path.default.join(this.homeDir, "repo");
|
|
41
|
+
this.configFile = import_path.default.join(this.homeDir, "config.json");
|
|
42
|
+
this.init();
|
|
43
|
+
}
|
|
44
|
+
init() {
|
|
45
|
+
import_fs_extra.default.ensureDirSync(this.homeDir);
|
|
46
|
+
import_fs_extra.default.ensureDirSync(this.repoDir);
|
|
47
|
+
if (!import_fs_extra.default.existsSync(this.configFile)) {
|
|
48
|
+
import_fs_extra.default.writeJsonSync(this.configFile, { system: import_os.default.platform() }, { spaces: 2 });
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
getHomeDir() {
|
|
52
|
+
return this.homeDir;
|
|
53
|
+
}
|
|
54
|
+
getRepoDir() {
|
|
55
|
+
return this.repoDir;
|
|
56
|
+
}
|
|
57
|
+
getSafeName(id) {
|
|
58
|
+
const parts = id.split(":");
|
|
59
|
+
if (parts.length < 2) {
|
|
60
|
+
return id.replace(/\//g, "__");
|
|
61
|
+
}
|
|
62
|
+
const type = parts[0];
|
|
63
|
+
const rest = parts.slice(1).join(":");
|
|
64
|
+
const safeRest = rest.replace(/\//g, "__");
|
|
65
|
+
return `${type}__${safeRest}`;
|
|
66
|
+
}
|
|
67
|
+
parseSafeName(safeName) {
|
|
68
|
+
const parts = safeName.split("__");
|
|
69
|
+
if (parts.length < 2) {
|
|
70
|
+
return safeName;
|
|
71
|
+
}
|
|
72
|
+
const type = parts[0];
|
|
73
|
+
const rest = parts.slice(1).join("/");
|
|
74
|
+
return `${type}:${rest}`;
|
|
75
|
+
}
|
|
76
|
+
getRepoPath(id) {
|
|
77
|
+
return import_path.default.join(this.repoDir, this.getSafeName(id));
|
|
78
|
+
}
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
// src/core/project.ts
|
|
82
|
+
var import_fs_extra2 = __toESM(require("fs-extra"));
|
|
83
|
+
var import_path2 = __toESM(require("path"));
|
|
84
|
+
var ProjectDetector = class _ProjectDetector {
|
|
85
|
+
cwd;
|
|
86
|
+
static AI_TOOLS = [
|
|
87
|
+
{ dir: ".opencode", type: "opencode" },
|
|
88
|
+
{ dir: ".cursor", type: "cursor" },
|
|
89
|
+
{ dir: ".gemini", type: "gemini" },
|
|
90
|
+
{ dir: ".antigravity", type: "antigravity" },
|
|
91
|
+
{ dir: ".claude", type: "claude" },
|
|
92
|
+
{ dir: ".github", type: "github" }
|
|
93
|
+
];
|
|
94
|
+
constructor(cwd = process.cwd()) {
|
|
95
|
+
this.cwd = cwd;
|
|
96
|
+
}
|
|
97
|
+
/**
|
|
98
|
+
* 检测所有存在的 AI 项目类型
|
|
99
|
+
*/
|
|
100
|
+
detectAll() {
|
|
101
|
+
const projects = [];
|
|
102
|
+
for (const tool of _ProjectDetector.AI_TOOLS) {
|
|
103
|
+
if (import_fs_extra2.default.existsSync(import_path2.default.join(this.cwd, tool.dir))) {
|
|
104
|
+
projects.push({
|
|
105
|
+
type: tool.type,
|
|
106
|
+
root: this.cwd,
|
|
107
|
+
skillDir: import_path2.default.join(this.cwd, tool.dir, "skills")
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
return projects;
|
|
112
|
+
}
|
|
113
|
+
/**
|
|
114
|
+
* 检测第一个存在的 AI 项目类型(向后兼容)
|
|
115
|
+
*/
|
|
116
|
+
detect() {
|
|
117
|
+
const projects = this.detectAll();
|
|
118
|
+
if (projects.length > 0) {
|
|
119
|
+
return projects[0];
|
|
120
|
+
}
|
|
121
|
+
return {
|
|
122
|
+
type: "unknown",
|
|
123
|
+
root: this.cwd
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
};
|
|
127
|
+
|
|
128
|
+
// src/commands/repo.ts
|
|
129
|
+
var import_inquirer = __toESM(require("inquirer"));
|
|
130
|
+
var import_fs_extra5 = __toESM(require("fs-extra"));
|
|
131
|
+
var import_path4 = __toESM(require("path"));
|
|
132
|
+
|
|
133
|
+
// src/core/skills.ts
|
|
134
|
+
var import_fs_extra3 = __toESM(require("fs-extra"));
|
|
135
|
+
var import_path3 = __toESM(require("path"));
|
|
136
|
+
var SkillRegistry = class {
|
|
137
|
+
versionsFile;
|
|
138
|
+
constructor(configManager) {
|
|
139
|
+
this.versionsFile = import_path3.default.join(configManager.getRepoDir(), "versions.json");
|
|
140
|
+
}
|
|
141
|
+
getStoredSkills() {
|
|
142
|
+
if (!import_fs_extra3.default.existsSync(this.versionsFile)) {
|
|
143
|
+
return {};
|
|
144
|
+
}
|
|
145
|
+
return import_fs_extra3.default.readJsonSync(this.versionsFile);
|
|
146
|
+
}
|
|
147
|
+
saveSkills(skills) {
|
|
148
|
+
import_fs_extra3.default.writeJsonSync(this.versionsFile, skills, { spaces: 2 });
|
|
149
|
+
}
|
|
150
|
+
addSkill(id, commitId, type, skillPath) {
|
|
151
|
+
const skills = this.getStoredSkills();
|
|
152
|
+
skills[id] = { commitId, type, path: skillPath };
|
|
153
|
+
this.saveSkills(skills);
|
|
154
|
+
}
|
|
155
|
+
removeSkill(id) {
|
|
156
|
+
const skills = this.getStoredSkills();
|
|
157
|
+
delete skills[id];
|
|
158
|
+
this.saveSkills(skills);
|
|
159
|
+
}
|
|
160
|
+
getSkill(id) {
|
|
161
|
+
const skills = this.getStoredSkills();
|
|
162
|
+
const stored = skills[id];
|
|
163
|
+
if (!stored) return void 0;
|
|
164
|
+
return { id, ...stored };
|
|
165
|
+
}
|
|
166
|
+
updateSkillVersion(id, newCommitId) {
|
|
167
|
+
const skills = this.getStoredSkills();
|
|
168
|
+
if (skills[id]) {
|
|
169
|
+
skills[id].commitId = newCommitId;
|
|
170
|
+
this.saveSkills(skills);
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
getAllSkills() {
|
|
174
|
+
const skills = this.getStoredSkills();
|
|
175
|
+
return Object.entries(skills).map(([id, stored]) => ({ id, ...stored }));
|
|
176
|
+
}
|
|
177
|
+
};
|
|
178
|
+
|
|
179
|
+
// src/core/git.ts
|
|
180
|
+
var import_execa = require("execa");
|
|
181
|
+
var import_fs_extra4 = __toESM(require("fs-extra"));
|
|
182
|
+
var GitManager = class {
|
|
183
|
+
async checkGitVersion() {
|
|
184
|
+
try {
|
|
185
|
+
const { stdout } = await (0, import_execa.execa)("git", ["--version"]);
|
|
186
|
+
const match = stdout.match(/git version (\d+)\.(\d+)/);
|
|
187
|
+
if (match) {
|
|
188
|
+
const major = parseInt(match[1], 10);
|
|
189
|
+
const minor = parseInt(match[2], 10);
|
|
190
|
+
if (major < 2 || major === 2 && minor < 25) {
|
|
191
|
+
throw new Error(`Git version must be >= 2.25. Found ${major}.${minor}`);
|
|
192
|
+
}
|
|
193
|
+
} else {
|
|
194
|
+
throw new Error("Could not parse git version");
|
|
195
|
+
}
|
|
196
|
+
} catch (error) {
|
|
197
|
+
throw new Error(`Failed to check git version: ${error}`);
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
normalizeUrl(inputUrl) {
|
|
201
|
+
let url = inputUrl.replace(/\/+$/, "");
|
|
202
|
+
url = url.split("?")[0].split("#")[0];
|
|
203
|
+
url = url.replace(/\.git$/, "");
|
|
204
|
+
const treePathMatch = url.match(/^(https:\/\/github\.com\/[^\/]+\/[^\/]+)\/tree\/([^\/]+)\/(.+)$/);
|
|
205
|
+
if (treePathMatch) {
|
|
206
|
+
const pathValue = treePathMatch[3].replace(/\/+$/, "");
|
|
207
|
+
return {
|
|
208
|
+
url: `${treePathMatch[1]}.git`,
|
|
209
|
+
branch: treePathMatch[2],
|
|
210
|
+
path: pathValue
|
|
211
|
+
};
|
|
212
|
+
}
|
|
213
|
+
const treeBranchMatch = url.match(/^(https:\/\/github\.com\/[^\/]+\/[^\/]+)\/tree\/([^\/]+)$/);
|
|
214
|
+
if (treeBranchMatch) {
|
|
215
|
+
return {
|
|
216
|
+
url: `${treeBranchMatch[1]}.git`,
|
|
217
|
+
branch: treeBranchMatch[2]
|
|
218
|
+
// 无 path,表示根目录
|
|
219
|
+
};
|
|
220
|
+
}
|
|
221
|
+
return {
|
|
222
|
+
url: `${url}.git`
|
|
223
|
+
};
|
|
224
|
+
}
|
|
225
|
+
async getRemoteHead(url, branch = "HEAD") {
|
|
226
|
+
const { stdout } = await (0, import_execa.execa)("git", ["ls-remote", url, branch]);
|
|
227
|
+
const match = stdout.match(/^([a-f0-9]+)\t/);
|
|
228
|
+
if (match) {
|
|
229
|
+
return match[1];
|
|
230
|
+
}
|
|
231
|
+
throw new Error(`Could not get remote HEAD for ${url} ${branch}`);
|
|
232
|
+
}
|
|
233
|
+
async cloneFull(url, dest) {
|
|
234
|
+
await (0, import_execa.execa)("git", ["clone", url, dest]);
|
|
235
|
+
}
|
|
236
|
+
async cloneSparse(url, dest, subPath, branch = "main") {
|
|
237
|
+
await (0, import_execa.execa)("git", ["clone", "--filter=blob:none", "--no-checkout", url, dest]);
|
|
238
|
+
await (0, import_execa.execa)("git", ["sparse-checkout", "init", "--cone"], { cwd: dest });
|
|
239
|
+
await (0, import_execa.execa)("git", ["sparse-checkout", "set", subPath], { cwd: dest });
|
|
240
|
+
await (0, import_execa.execa)("git", ["checkout", branch], { cwd: dest });
|
|
241
|
+
}
|
|
242
|
+
async pull(cwd) {
|
|
243
|
+
await (0, import_execa.execa)("git", ["pull"], { cwd });
|
|
244
|
+
}
|
|
245
|
+
async fetch(cwd) {
|
|
246
|
+
await (0, import_execa.execa)("git", ["fetch", "origin"], { cwd });
|
|
247
|
+
}
|
|
248
|
+
/**
|
|
249
|
+
* 检查远程仓库中的 SKILL.md 是否存在
|
|
250
|
+
* 使用 git ls-remote + git ls-tree 检查文件
|
|
251
|
+
*/
|
|
252
|
+
async checkRemoteSkillMd(userRepo, branch = "main", subPath) {
|
|
253
|
+
const url = `https://github.com/${userRepo}.git`;
|
|
254
|
+
const skillPath = subPath ? `${subPath}/SKILL.md` : "SKILL.md";
|
|
255
|
+
try {
|
|
256
|
+
const { stdout: refStdout } = await (0, import_execa.execa)("git", ["ls-remote", url, `refs/heads/${branch}`]);
|
|
257
|
+
const commitMatch = refStdout.match(/^([a-f0-9]+)\t/);
|
|
258
|
+
if (!commitMatch) {
|
|
259
|
+
return false;
|
|
260
|
+
}
|
|
261
|
+
const commitHash = commitMatch[1];
|
|
262
|
+
await (0, import_execa.execa)("git", ["archive", "--remote", url, commitHash, skillPath]);
|
|
263
|
+
return true;
|
|
264
|
+
} catch {
|
|
265
|
+
try {
|
|
266
|
+
const tmpDir = `/tmp/skm-check-${Date.now()}`;
|
|
267
|
+
try {
|
|
268
|
+
await (0, import_execa.execa)("git", ["clone", "--depth=1", "--filter=blob:none", "--no-checkout", url, tmpDir]);
|
|
269
|
+
await (0, import_execa.execa)("git", ["sparse-checkout", "init", "--cone"], { cwd: tmpDir });
|
|
270
|
+
await (0, import_execa.execa)("git", ["sparse-checkout", "set", subPath || "."], { cwd: tmpDir });
|
|
271
|
+
await (0, import_execa.execa)("git", ["checkout", branch], { cwd: tmpDir });
|
|
272
|
+
const skillMdPath = subPath ? `${tmpDir}/${subPath}/SKILL.md` : `${tmpDir}/SKILL.md`;
|
|
273
|
+
const exists = import_fs_extra4.default.existsSync(skillMdPath);
|
|
274
|
+
await import_fs_extra4.default.remove(tmpDir);
|
|
275
|
+
return exists;
|
|
276
|
+
} catch {
|
|
277
|
+
await import_fs_extra4.default.remove(tmpDir).catch(() => {
|
|
278
|
+
});
|
|
279
|
+
return false;
|
|
280
|
+
}
|
|
281
|
+
} catch {
|
|
282
|
+
return false;
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
/**
|
|
287
|
+
* 获取远程仓库默认分支
|
|
288
|
+
* 使用 git ls-remote 获取 HEAD 引用
|
|
289
|
+
*/
|
|
290
|
+
async getDefaultBranch(userRepo) {
|
|
291
|
+
const url = `https://github.com/${userRepo}.git`;
|
|
292
|
+
try {
|
|
293
|
+
const { stdout } = await (0, import_execa.execa)("git", ["ls-remote", "--symref", url, "HEAD"]);
|
|
294
|
+
const match = stdout.match(/ref: refs\/heads\/([^\t\n]+)/);
|
|
295
|
+
if (match) {
|
|
296
|
+
return match[1];
|
|
297
|
+
}
|
|
298
|
+
} catch {
|
|
299
|
+
}
|
|
300
|
+
return "main";
|
|
301
|
+
}
|
|
302
|
+
/**
|
|
303
|
+
* 从本地仓库获取指定路径的最新 commit id
|
|
304
|
+
* 用于已经 clone 到本地的仓库
|
|
305
|
+
*/
|
|
306
|
+
async getLocalPathCommitId(repoDir, subPath) {
|
|
307
|
+
try {
|
|
308
|
+
const { stdout: stdout2 } = await (0, import_execa.execa)("git", ["log", "-1", "--format=%H", "--", subPath], { cwd: repoDir });
|
|
309
|
+
if (stdout2.trim()) {
|
|
310
|
+
return stdout2.trim();
|
|
311
|
+
}
|
|
312
|
+
} catch {
|
|
313
|
+
}
|
|
314
|
+
const { stdout } = await (0, import_execa.execa)("git", ["rev-parse", "HEAD"], { cwd: repoDir });
|
|
315
|
+
return stdout.trim();
|
|
316
|
+
}
|
|
317
|
+
/**
|
|
318
|
+
* 获取远程分支中指定路径的最新 commit id
|
|
319
|
+
* 用于更新检测(在 fetch 之后使用)
|
|
320
|
+
*/
|
|
321
|
+
async getRemotePathCommitId(repoDir, remoteBranch, subPath) {
|
|
322
|
+
try {
|
|
323
|
+
const { stdout: stdout2 } = await (0, import_execa.execa)("git", ["log", "-1", "--format=%H", remoteBranch, "--", subPath], { cwd: repoDir });
|
|
324
|
+
if (stdout2.trim()) {
|
|
325
|
+
return stdout2.trim();
|
|
326
|
+
}
|
|
327
|
+
} catch {
|
|
328
|
+
}
|
|
329
|
+
const { stdout } = await (0, import_execa.execa)("git", ["rev-parse", remoteBranch], { cwd: repoDir });
|
|
330
|
+
return stdout.trim();
|
|
331
|
+
}
|
|
332
|
+
};
|
|
333
|
+
|
|
334
|
+
// src/commands/repo.ts
|
|
335
|
+
async function repoMenu() {
|
|
336
|
+
const configManager = new ConfigManager();
|
|
337
|
+
const skillRegistry = new SkillRegistry(configManager);
|
|
338
|
+
while (true) {
|
|
339
|
+
const skills = skillRegistry.getAllSkills().sort((a, b) => a.id.localeCompare(b.id));
|
|
340
|
+
console.log("\n=== Repository Management ===");
|
|
341
|
+
console.log("a. Add skill");
|
|
342
|
+
console.log("---");
|
|
343
|
+
const menuMap = {
|
|
344
|
+
"a": { action: "add" },
|
|
345
|
+
"0": { action: "back" }
|
|
346
|
+
};
|
|
347
|
+
if (skills.length === 0) {
|
|
348
|
+
console.log("(No skills in repository)");
|
|
349
|
+
} else {
|
|
350
|
+
skills.forEach((s, index) => {
|
|
351
|
+
const num = (index + 1).toString();
|
|
352
|
+
console.log(`${num}. ${s.id}:${s.commitId.substring(0, 7)}`);
|
|
353
|
+
menuMap[num] = { action: "manage", skill: s };
|
|
354
|
+
});
|
|
355
|
+
}
|
|
356
|
+
console.log("---");
|
|
357
|
+
console.log("0. Back to main menu");
|
|
358
|
+
const answer = await import_inquirer.default.prompt([
|
|
359
|
+
{
|
|
360
|
+
type: "input",
|
|
361
|
+
name: "choice",
|
|
362
|
+
message: "Select an option:",
|
|
363
|
+
validate: (input) => menuMap[input.toLowerCase()] ? true : "Invalid option"
|
|
364
|
+
}
|
|
365
|
+
]);
|
|
366
|
+
const choice = menuMap[answer.choice.toLowerCase()];
|
|
367
|
+
const { action, skill } = choice;
|
|
368
|
+
if (action === "back") {
|
|
369
|
+
break;
|
|
370
|
+
} else if (action === "add") {
|
|
371
|
+
await addSkillInteractive();
|
|
372
|
+
} else if (action === "manage" && skill) {
|
|
373
|
+
await manageSkill(skill);
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
async function checkSingleSkillUpdate(skill) {
|
|
378
|
+
const configManager = new ConfigManager();
|
|
379
|
+
const gitManager = new GitManager();
|
|
380
|
+
if (skill.type !== "github") return null;
|
|
381
|
+
try {
|
|
382
|
+
const parts = skill.id.split(":");
|
|
383
|
+
if (parts.length < 2) return null;
|
|
384
|
+
const repoPath = parts[1];
|
|
385
|
+
let userRepo = repoPath;
|
|
386
|
+
if (skill.path) {
|
|
387
|
+
if (repoPath.endsWith(skill.path)) {
|
|
388
|
+
userRepo = repoPath.substring(0, repoPath.length - skill.path.length - 1);
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
const url = `https://github.com/${userRepo}.git`;
|
|
392
|
+
const localRepoDir = configManager.getRepoPath(skill.id);
|
|
393
|
+
let localCommit;
|
|
394
|
+
if (skill.path) {
|
|
395
|
+
localCommit = await gitManager.getLocalPathCommitId(localRepoDir, skill.path);
|
|
396
|
+
} else {
|
|
397
|
+
localCommit = await gitManager.getLocalPathCommitId(localRepoDir, ".");
|
|
398
|
+
}
|
|
399
|
+
console.log("Checking for updates...");
|
|
400
|
+
await gitManager.fetch(localRepoDir);
|
|
401
|
+
const branch = await gitManager.getDefaultBranch(userRepo);
|
|
402
|
+
let remoteHead;
|
|
403
|
+
if (skill.path) {
|
|
404
|
+
remoteHead = await gitManager.getRemotePathCommitId(localRepoDir, `origin/${branch}`, skill.path);
|
|
405
|
+
} else {
|
|
406
|
+
remoteHead = await gitManager.getRemotePathCommitId(localRepoDir, `origin/${branch}`, ".");
|
|
407
|
+
}
|
|
408
|
+
if (remoteHead && remoteHead !== localCommit) {
|
|
409
|
+
return { skill, url, remoteHead, branch };
|
|
410
|
+
}
|
|
411
|
+
} catch (error) {
|
|
412
|
+
console.error(`Failed to check update: ${error.message}`);
|
|
413
|
+
}
|
|
414
|
+
return null;
|
|
415
|
+
}
|
|
416
|
+
async function manageSkill(skill) {
|
|
417
|
+
console.log(`
|
|
418
|
+
=== Manage: ${skill.id} ===`);
|
|
419
|
+
const update = await checkSingleSkillUpdate(skill);
|
|
420
|
+
const hasUpdate = update !== null;
|
|
421
|
+
const menuMap = {
|
|
422
|
+
"0": "back"
|
|
423
|
+
};
|
|
424
|
+
let optionNum = 1;
|
|
425
|
+
if (hasUpdate && update) {
|
|
426
|
+
console.log(`${optionNum}. Update (${skill.commitId.substring(0, 7)} -> ${update.remoteHead.substring(0, 7)})`);
|
|
427
|
+
menuMap[optionNum.toString()] = "update";
|
|
428
|
+
optionNum++;
|
|
429
|
+
} else {
|
|
430
|
+
console.log("(No updates available)");
|
|
431
|
+
}
|
|
432
|
+
console.log(`${optionNum}. Delete`);
|
|
433
|
+
menuMap[optionNum.toString()] = "delete";
|
|
434
|
+
console.log("0. Back");
|
|
435
|
+
const answer = await import_inquirer.default.prompt([
|
|
436
|
+
{
|
|
437
|
+
type: "input",
|
|
438
|
+
name: "action",
|
|
439
|
+
message: "Select an option:",
|
|
440
|
+
validate: (input) => menuMap[input] ? true : "Invalid option"
|
|
441
|
+
}
|
|
442
|
+
]);
|
|
443
|
+
const action = menuMap[answer.action];
|
|
444
|
+
if (action === "update" && update) {
|
|
445
|
+
await updateSingleSkill(skill, update);
|
|
446
|
+
} else if (action === "delete") {
|
|
447
|
+
await deleteSkill(skill.id);
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
async function updateSingleSkill(skill, update) {
|
|
451
|
+
const configManager = new ConfigManager();
|
|
452
|
+
const skillRegistry = new SkillRegistry(configManager);
|
|
453
|
+
const gitManager = new GitManager();
|
|
454
|
+
try {
|
|
455
|
+
console.log(`Updating ${skill.id}...`);
|
|
456
|
+
const destPath = configManager.getRepoPath(skill.id);
|
|
457
|
+
await gitManager.pull(destPath);
|
|
458
|
+
skillRegistry.updateSkillVersion(skill.id, update.remoteHead);
|
|
459
|
+
console.log(`Updated ${skill.id}`);
|
|
460
|
+
} catch (error) {
|
|
461
|
+
console.error(`Failed to update ${skill.id}: ${error.message}`);
|
|
462
|
+
}
|
|
463
|
+
}
|
|
464
|
+
async function addSkillInteractive(url) {
|
|
465
|
+
const configManager = new ConfigManager();
|
|
466
|
+
const skillRegistry = new SkillRegistry(configManager);
|
|
467
|
+
const gitManager = new GitManager();
|
|
468
|
+
let repoUrl = url;
|
|
469
|
+
if (!repoUrl) {
|
|
470
|
+
const answer = await import_inquirer.default.prompt([
|
|
471
|
+
{
|
|
472
|
+
type: "input",
|
|
473
|
+
name: "url",
|
|
474
|
+
message: "Enter GitHub URL:",
|
|
475
|
+
validate: (input) => input.length > 0 || "URL is required"
|
|
476
|
+
}
|
|
477
|
+
]);
|
|
478
|
+
repoUrl = answer.url;
|
|
479
|
+
}
|
|
480
|
+
if (!repoUrl) return;
|
|
481
|
+
try {
|
|
482
|
+
await gitManager.checkGitVersion();
|
|
483
|
+
const gitInfo = gitManager.normalizeUrl(repoUrl);
|
|
484
|
+
const match = gitInfo.url.match(/github\.com\/([^\/]+\/[^\/]+)(\.git)?$/);
|
|
485
|
+
if (!match) {
|
|
486
|
+
throw new Error("Only GitHub URLs are supported for now.");
|
|
487
|
+
}
|
|
488
|
+
const userRepo = match[1].replace(/\.git$/, "");
|
|
489
|
+
const branch = gitInfo.branch || await gitManager.getDefaultBranch(userRepo);
|
|
490
|
+
console.log("Checking for SKILL.md...");
|
|
491
|
+
const hasSkillMd = await gitManager.checkRemoteSkillMd(userRepo, branch, gitInfo.path);
|
|
492
|
+
if (!hasSkillMd) {
|
|
493
|
+
console.error("Error: SKILL.md not found at the specified path.");
|
|
494
|
+
console.error(`Expected location: https://github.com/${userRepo}/blob/${branch}/${gitInfo.path ? gitInfo.path + "/" : ""}SKILL.md`);
|
|
495
|
+
return;
|
|
496
|
+
}
|
|
497
|
+
let id = `github:${userRepo}`;
|
|
498
|
+
if (gitInfo.path) {
|
|
499
|
+
id += `/${gitInfo.path}`;
|
|
500
|
+
}
|
|
501
|
+
const existingSkill = skillRegistry.getSkill(id);
|
|
502
|
+
if (existingSkill) {
|
|
503
|
+
const confirm = await import_inquirer.default.prompt([
|
|
504
|
+
{
|
|
505
|
+
type: "confirm",
|
|
506
|
+
name: "overwrite",
|
|
507
|
+
message: `Skill ${id} already exists. Overwrite?`,
|
|
508
|
+
default: false
|
|
509
|
+
}
|
|
510
|
+
]);
|
|
511
|
+
if (!confirm.overwrite) {
|
|
512
|
+
console.log("Operation cancelled.");
|
|
513
|
+
return;
|
|
514
|
+
}
|
|
515
|
+
await import_fs_extra5.default.remove(configManager.getRepoPath(id));
|
|
516
|
+
}
|
|
517
|
+
const destPath = configManager.getRepoPath(id);
|
|
518
|
+
import_fs_extra5.default.ensureDirSync(import_path4.default.dirname(destPath));
|
|
519
|
+
console.log(`Cloning ${id}...`);
|
|
520
|
+
if (gitInfo.path) {
|
|
521
|
+
await gitManager.cloneSparse(gitInfo.url, destPath, gitInfo.path, branch);
|
|
522
|
+
} else {
|
|
523
|
+
await gitManager.cloneFull(gitInfo.url, destPath);
|
|
524
|
+
}
|
|
525
|
+
let commitId;
|
|
526
|
+
if (gitInfo.path) {
|
|
527
|
+
commitId = await gitManager.getLocalPathCommitId(destPath, gitInfo.path);
|
|
528
|
+
} else {
|
|
529
|
+
commitId = await gitManager.getLocalPathCommitId(destPath, ".");
|
|
530
|
+
}
|
|
531
|
+
skillRegistry.addSkill(id, commitId, "github", gitInfo.path);
|
|
532
|
+
console.log(`Skill ${id} added successfully.`);
|
|
533
|
+
} catch (error) {
|
|
534
|
+
console.error(`Failed to add skill: ${error.message}`);
|
|
535
|
+
}
|
|
536
|
+
}
|
|
537
|
+
async function deleteSkill(id) {
|
|
538
|
+
const configManager = new ConfigManager();
|
|
539
|
+
const skillRegistry = new SkillRegistry(configManager);
|
|
540
|
+
let skillId = id;
|
|
541
|
+
if (!skillId) {
|
|
542
|
+
const skills = skillRegistry.getAllSkills();
|
|
543
|
+
if (skills.length === 0) {
|
|
544
|
+
console.log("No skills to delete.");
|
|
545
|
+
return;
|
|
546
|
+
}
|
|
547
|
+
const answer = await import_inquirer.default.prompt([
|
|
548
|
+
{
|
|
549
|
+
type: "list",
|
|
550
|
+
name: "skillId",
|
|
551
|
+
message: "Select a skill to delete:",
|
|
552
|
+
choices: skills.map((s) => s.id)
|
|
553
|
+
}
|
|
554
|
+
]);
|
|
555
|
+
skillId = answer.skillId;
|
|
556
|
+
}
|
|
557
|
+
if (!skillId) return;
|
|
558
|
+
const confirm = await import_inquirer.default.prompt([
|
|
559
|
+
{
|
|
560
|
+
type: "confirm",
|
|
561
|
+
name: "sure",
|
|
562
|
+
message: `Are you sure you want to delete ${skillId}?`,
|
|
563
|
+
default: false
|
|
564
|
+
}
|
|
565
|
+
]);
|
|
566
|
+
if (confirm.sure) {
|
|
567
|
+
const repoPath = configManager.getRepoPath(skillId);
|
|
568
|
+
await import_fs_extra5.default.remove(repoPath);
|
|
569
|
+
skillRegistry.removeSkill(skillId);
|
|
570
|
+
console.log(`Skill ${skillId} deleted.`);
|
|
571
|
+
} else {
|
|
572
|
+
console.log("Deletion cancelled.");
|
|
573
|
+
}
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
// src/commands/project.ts
|
|
577
|
+
var import_inquirer2 = __toESM(require("inquirer"));
|
|
578
|
+
var import_fs_extra6 = __toESM(require("fs-extra"));
|
|
579
|
+
var import_path5 = __toESM(require("path"));
|
|
580
|
+
var import_os2 = __toESM(require("os"));
|
|
581
|
+
async function linkSkillToProject(skillId, projectInfo) {
|
|
582
|
+
const configManager = new ConfigManager();
|
|
583
|
+
const skillRegistry = new SkillRegistry(configManager);
|
|
584
|
+
if (!projectInfo.skillDir) {
|
|
585
|
+
console.log("No skill directory for this project.");
|
|
586
|
+
return;
|
|
587
|
+
}
|
|
588
|
+
const skill = skillRegistry.getSkill(skillId);
|
|
589
|
+
if (!skill) {
|
|
590
|
+
console.log(`Skill ${skillId} not found in repository.`);
|
|
591
|
+
return;
|
|
592
|
+
}
|
|
593
|
+
import_fs_extra6.default.ensureDirSync(projectInfo.skillDir);
|
|
594
|
+
const safeName = configManager.getSafeName(skill.id);
|
|
595
|
+
const linkPath = import_path5.default.join(projectInfo.skillDir, safeName);
|
|
596
|
+
const repoPath = configManager.getRepoPath(skill.id);
|
|
597
|
+
let targetPath = repoPath;
|
|
598
|
+
if (skill.path) {
|
|
599
|
+
targetPath = import_path5.default.join(repoPath, skill.path);
|
|
600
|
+
}
|
|
601
|
+
if (import_fs_extra6.default.existsSync(linkPath)) {
|
|
602
|
+
console.log(`Skill ${skillId} is already linked.`);
|
|
603
|
+
return;
|
|
604
|
+
}
|
|
605
|
+
try {
|
|
606
|
+
const type = import_os2.default.platform() === "win32" ? "junction" : "dir";
|
|
607
|
+
await import_fs_extra6.default.ensureSymlink(targetPath, linkPath, type);
|
|
608
|
+
console.log(`Linked ${skill.id}`);
|
|
609
|
+
} catch (error) {
|
|
610
|
+
console.error(`Failed to link ${skill.id}: ${error.message}`);
|
|
611
|
+
}
|
|
612
|
+
}
|
|
613
|
+
async function unlinkSkillFromProject(skillId, projectInfo) {
|
|
614
|
+
const configManager = new ConfigManager();
|
|
615
|
+
if (!projectInfo.skillDir) {
|
|
616
|
+
console.log("No skill directory for this project.");
|
|
617
|
+
return;
|
|
618
|
+
}
|
|
619
|
+
const safeName = configManager.getSafeName(skillId);
|
|
620
|
+
const linkPath = import_path5.default.join(projectInfo.skillDir, safeName);
|
|
621
|
+
if (!import_fs_extra6.default.existsSync(linkPath)) {
|
|
622
|
+
console.log(`Skill ${skillId} is not linked.`);
|
|
623
|
+
return;
|
|
624
|
+
}
|
|
625
|
+
try {
|
|
626
|
+
await import_fs_extra6.default.remove(linkPath);
|
|
627
|
+
console.log(`Unlinked ${skillId}`);
|
|
628
|
+
} catch (error) {
|
|
629
|
+
console.error(`Failed to unlink ${skillId}: ${error.message}`);
|
|
630
|
+
}
|
|
631
|
+
}
|
|
632
|
+
function findBrokenLinks(skillDir) {
|
|
633
|
+
const broken = [];
|
|
634
|
+
if (!import_fs_extra6.default.existsSync(skillDir)) {
|
|
635
|
+
return broken;
|
|
636
|
+
}
|
|
637
|
+
const entries = import_fs_extra6.default.readdirSync(skillDir);
|
|
638
|
+
for (const entry of entries) {
|
|
639
|
+
const linkPath = import_path5.default.join(skillDir, entry);
|
|
640
|
+
try {
|
|
641
|
+
const stats = import_fs_extra6.default.lstatSync(linkPath);
|
|
642
|
+
if (stats.isSymbolicLink()) {
|
|
643
|
+
const target = import_fs_extra6.default.readlinkSync(linkPath);
|
|
644
|
+
if (!import_fs_extra6.default.existsSync(target)) {
|
|
645
|
+
broken.push(entry);
|
|
646
|
+
}
|
|
647
|
+
}
|
|
648
|
+
} catch {
|
|
649
|
+
}
|
|
650
|
+
}
|
|
651
|
+
return broken;
|
|
652
|
+
}
|
|
653
|
+
async function manageProjectSkills(projectInfo) {
|
|
654
|
+
const configManager = new ConfigManager();
|
|
655
|
+
const skillRegistry = new SkillRegistry(configManager);
|
|
656
|
+
if (!projectInfo) {
|
|
657
|
+
const projectDetector = new ProjectDetector();
|
|
658
|
+
projectInfo = projectDetector.detect();
|
|
659
|
+
}
|
|
660
|
+
if (projectInfo.type === "unknown" || !projectInfo.skillDir) {
|
|
661
|
+
console.log("No supported AI project detected in current directory.");
|
|
662
|
+
return;
|
|
663
|
+
}
|
|
664
|
+
console.log(`
|
|
665
|
+
Managing ${projectInfo.type} project skills`);
|
|
666
|
+
console.log(`Skill directory: ${projectInfo.skillDir}
|
|
667
|
+
`);
|
|
668
|
+
import_fs_extra6.default.ensureDirSync(projectInfo.skillDir);
|
|
669
|
+
const brokenLinks = findBrokenLinks(projectInfo.skillDir);
|
|
670
|
+
if (brokenLinks.length > 0) {
|
|
671
|
+
console.log("\u26A0 Found broken symlinks:");
|
|
672
|
+
for (const link of brokenLinks) {
|
|
673
|
+
console.log(` - ${link}`);
|
|
674
|
+
}
|
|
675
|
+
const cleanupAnswer = await import_inquirer2.default.prompt([
|
|
676
|
+
{
|
|
677
|
+
type: "confirm",
|
|
678
|
+
name: "cleanup",
|
|
679
|
+
message: "Remove broken symlinks?",
|
|
680
|
+
default: true
|
|
681
|
+
}
|
|
682
|
+
]);
|
|
683
|
+
if (cleanupAnswer.cleanup) {
|
|
684
|
+
for (const link of brokenLinks) {
|
|
685
|
+
const linkPath = import_path5.default.join(projectInfo.skillDir, link);
|
|
686
|
+
await import_fs_extra6.default.remove(linkPath);
|
|
687
|
+
console.log(`Removed broken link: ${link}`);
|
|
688
|
+
}
|
|
689
|
+
}
|
|
690
|
+
console.log("");
|
|
691
|
+
}
|
|
692
|
+
const skills = skillRegistry.getAllSkills().sort((a, b) => a.id.localeCompare(b.id));
|
|
693
|
+
if (skills.length === 0) {
|
|
694
|
+
console.log('No skills in global repository. Add skills first using "repo" menu.');
|
|
695
|
+
return;
|
|
696
|
+
}
|
|
697
|
+
const getLinkedSkills = () => {
|
|
698
|
+
const linked = /* @__PURE__ */ new Set();
|
|
699
|
+
for (const skill of skills) {
|
|
700
|
+
const safeName = configManager.getSafeName(skill.id);
|
|
701
|
+
const linkPath = import_path5.default.join(projectInfo.skillDir, safeName);
|
|
702
|
+
if (import_fs_extra6.default.existsSync(linkPath)) {
|
|
703
|
+
linked.add(skill.id);
|
|
704
|
+
}
|
|
705
|
+
}
|
|
706
|
+
return linked;
|
|
707
|
+
};
|
|
708
|
+
const linkedSkills = getLinkedSkills();
|
|
709
|
+
console.log("\n=== Link/Unlink Skills ===");
|
|
710
|
+
console.log("Use space to select/unselect, enter to confirm:\n");
|
|
711
|
+
const answer = await import_inquirer2.default.prompt([
|
|
712
|
+
{
|
|
713
|
+
type: "checkbox",
|
|
714
|
+
name: "selected",
|
|
715
|
+
message: "Select skills to link:",
|
|
716
|
+
choices: skills.map((skill) => ({
|
|
717
|
+
name: skill.id,
|
|
718
|
+
value: skill.id,
|
|
719
|
+
checked: linkedSkills.has(skill.id)
|
|
720
|
+
}))
|
|
721
|
+
}
|
|
722
|
+
]);
|
|
723
|
+
const selectedSet = new Set(answer.selected);
|
|
724
|
+
for (const skill of skills) {
|
|
725
|
+
const isCurrentlyLinked = linkedSkills.has(skill.id);
|
|
726
|
+
const shouldBeLinked = selectedSet.has(skill.id);
|
|
727
|
+
if (!isCurrentlyLinked && shouldBeLinked) {
|
|
728
|
+
await linkSkillToProject(skill.id, projectInfo);
|
|
729
|
+
} else if (isCurrentlyLinked && !shouldBeLinked) {
|
|
730
|
+
await unlinkSkillFromProject(skill.id, projectInfo);
|
|
731
|
+
}
|
|
732
|
+
}
|
|
733
|
+
}
|
|
734
|
+
|
|
735
|
+
// src/index.ts
|
|
736
|
+
async function showConfig() {
|
|
737
|
+
const configManager = new ConfigManager();
|
|
738
|
+
console.log(`
|
|
739
|
+
Config Directory: ${configManager.getHomeDir()}`);
|
|
740
|
+
const configFile = `${configManager.getHomeDir()}/config.json`;
|
|
741
|
+
if (import_fs_extra7.default.existsSync(configFile)) {
|
|
742
|
+
const config = import_fs_extra7.default.readJsonSync(configFile);
|
|
743
|
+
console.log("Configuration:", JSON.stringify(config, null, 2));
|
|
744
|
+
}
|
|
745
|
+
}
|
|
746
|
+
async function mainMenu() {
|
|
747
|
+
const projectDetector = new ProjectDetector();
|
|
748
|
+
while (true) {
|
|
749
|
+
const projects = projectDetector.detectAll();
|
|
750
|
+
console.log("\n=== Skills Management (skm) ===");
|
|
751
|
+
console.log("1. repo - Manage global skills repository");
|
|
752
|
+
const menuMap = {
|
|
753
|
+
"1": { action: "repo" }
|
|
754
|
+
};
|
|
755
|
+
let currentIndex = 2;
|
|
756
|
+
for (const project2 of projects) {
|
|
757
|
+
console.log(`${currentIndex}. list(${project2.type}) - Manage skills for ${project2.type}`);
|
|
758
|
+
menuMap[currentIndex.toString()] = { action: "list", project: project2 };
|
|
759
|
+
currentIndex++;
|
|
760
|
+
}
|
|
761
|
+
console.log("0. Exit");
|
|
762
|
+
menuMap["0"] = { action: "exit" };
|
|
763
|
+
const answer = await import_inquirer3.default.prompt([
|
|
764
|
+
{
|
|
765
|
+
type: "input",
|
|
766
|
+
name: "choice",
|
|
767
|
+
message: "Select an option (enter number):",
|
|
768
|
+
validate: (input) => menuMap[input] ? true : "Invalid option"
|
|
769
|
+
}
|
|
770
|
+
]);
|
|
771
|
+
const { action, project } = menuMap[answer.choice];
|
|
772
|
+
if (action === "exit") {
|
|
773
|
+
process.exit(0);
|
|
774
|
+
} else if (action === "repo") {
|
|
775
|
+
await repoMenu();
|
|
776
|
+
} else if (action === "list" && project) {
|
|
777
|
+
await manageProjectSkills(project);
|
|
778
|
+
}
|
|
779
|
+
}
|
|
780
|
+
}
|
|
781
|
+
async function main() {
|
|
782
|
+
const args = process.argv.slice(2);
|
|
783
|
+
if (args.includes("--config")) {
|
|
784
|
+
await showConfig();
|
|
785
|
+
return;
|
|
786
|
+
}
|
|
787
|
+
if (args.includes("--help") || args.includes("-h")) {
|
|
788
|
+
console.log(`
|
|
789
|
+
Skills Management CLI (skm)
|
|
790
|
+
|
|
791
|
+
Usage:
|
|
792
|
+
skm Enter interactive mode
|
|
793
|
+
skm --config View configuration
|
|
794
|
+
`);
|
|
795
|
+
return;
|
|
796
|
+
}
|
|
797
|
+
await mainMenu();
|
|
798
|
+
}
|
|
799
|
+
main().catch((error) => {
|
|
800
|
+
if (error.name === "ExitPromptError" || error.message?.includes("force closed")) {
|
|
801
|
+
console.log("\nHave a nice Day!");
|
|
802
|
+
process.exit(0);
|
|
803
|
+
}
|
|
804
|
+
console.error(error);
|
|
805
|
+
process.exit(1);
|
|
806
|
+
});
|
package/package.json
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@nnnggel/skills-management",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"main": "index.js",
|
|
5
|
+
"description": "A CLI tool to manage and synchronize AI coding agent skills",
|
|
6
|
+
"bin": {
|
|
7
|
+
"skm": "./dist/index.js"
|
|
8
|
+
},
|
|
9
|
+
"files": [
|
|
10
|
+
"dist"
|
|
11
|
+
],
|
|
12
|
+
"scripts": {
|
|
13
|
+
"test": "vitest run",
|
|
14
|
+
"build": "tsup src/index.ts --format cjs --clean --dts",
|
|
15
|
+
"prepublishOnly": "npm run build"
|
|
16
|
+
},
|
|
17
|
+
"publishConfig": {
|
|
18
|
+
"access": "public"
|
|
19
|
+
},
|
|
20
|
+
"keywords": [
|
|
21
|
+
"ai",
|
|
22
|
+
"skills",
|
|
23
|
+
"cli",
|
|
24
|
+
"management",
|
|
25
|
+
"agent"
|
|
26
|
+
],
|
|
27
|
+
"author": "",
|
|
28
|
+
"license": "ISC",
|
|
29
|
+
"dependencies": {
|
|
30
|
+
"commander": "^14.0.2",
|
|
31
|
+
"execa": "^9.6.1",
|
|
32
|
+
"fs-extra": "^11.3.3",
|
|
33
|
+
"inquirer": "^13.2.1",
|
|
34
|
+
"zod": "^4.3.6"
|
|
35
|
+
},
|
|
36
|
+
"devDependencies": {
|
|
37
|
+
"@types/fs-extra": "^11.0.4",
|
|
38
|
+
"@types/inquirer": "^9.0.9",
|
|
39
|
+
"@types/node": "^25.0.10",
|
|
40
|
+
"tsup": "^8.3.6",
|
|
41
|
+
"typescript": "^5.9.3",
|
|
42
|
+
"vitest": "^4.0.18"
|
|
43
|
+
}
|
|
44
|
+
}
|