general-skills 0.1.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 +155 -0
- package/bin/gskills.mjs +113 -0
- package/docs/compatibility.md +110 -0
- package/package.json +42 -0
- package/scripts/install-skills.mjs +78 -0
- package/scripts/lib/aicoding-select.mjs +84 -0
- package/scripts/lib/remote-skills.mjs +256 -0
- package/scripts/lib/skill-utils.mjs +300 -0
- package/scripts/new-skill.mjs +98 -0
- package/scripts/validate-skills.mjs +42 -0
- package/templates/skill/SKILL.md +14 -0
package/README.md
ADDED
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
# General Skills
|
|
2
|
+
|
|
3
|
+
一个面向 AI 编程工具的通用 Skills 仓库。这里的 `skills/` 是技能源码目录,npm 包只发布轻量级命令行工具 `gskills`,不会把 `skills/` 打进包里。这样后续新增或更新技能后,用户不需要升级 npm 包,也可以从 GitHub 仓库实时拉取最新技能。
|
|
4
|
+
|
|
5
|
+
默认远程源:
|
|
6
|
+
|
|
7
|
+
```text
|
|
8
|
+
zhy15608103017/generalSkills@main
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
## 通过 npm 使用
|
|
12
|
+
|
|
13
|
+
不全局安装,直接使用:
|
|
14
|
+
|
|
15
|
+
```powershell
|
|
16
|
+
npx general-skills list
|
|
17
|
+
npx general-skills add my-skill another-skill
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
全局安装后使用:
|
|
21
|
+
|
|
22
|
+
```powershell
|
|
23
|
+
npm install -g general-skills
|
|
24
|
+
gskills list
|
|
25
|
+
gskills aicodings
|
|
26
|
+
gskills add my-skill another-skill
|
|
27
|
+
gskills remove my-skill --aicoding claude
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
当 `gskills add` 或 `gskills remove` 在交互式终端中运行,并且没有传入 `--aicoding` 时,会出现 AI 编程工具选择列表。默认选项是 `default`,安装目录是 `.agents/skills`。在 CI 或脚本等非交互环境中,会自动使用 `default`,避免命令卡住。
|
|
31
|
+
|
|
32
|
+
## 支持的 AI 编程工具
|
|
33
|
+
|
|
34
|
+
```powershell
|
|
35
|
+
gskills aicodings
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
当前目标:
|
|
39
|
+
|
|
40
|
+
| 目标 | 安装目录 |
|
|
41
|
+
| --- | --- |
|
|
42
|
+
| `default` | `.agents/skills` |
|
|
43
|
+
| `codex` | `.agents/skills` |
|
|
44
|
+
| `claude` | `.claude/skills` |
|
|
45
|
+
| `cursor` | `.cursor/skills` |
|
|
46
|
+
| `trae` | `.trae/skills` |
|
|
47
|
+
| `windsurf` | `.windsurf/skills` |
|
|
48
|
+
| `gemini` | `.gemini/skills` |
|
|
49
|
+
| `opencode` | `.opencode/skills` |
|
|
50
|
+
|
|
51
|
+
也可以直接指定目标:
|
|
52
|
+
|
|
53
|
+
```powershell
|
|
54
|
+
gskills add my-skill --aicoding default
|
|
55
|
+
gskills add my-skill --aicoding codex
|
|
56
|
+
gskills add my-skill --aicoding claude
|
|
57
|
+
gskills add my-skill --aicoding cursor
|
|
58
|
+
gskills add my-skill --aicoding trae
|
|
59
|
+
gskills add my-skill --aicoding windsurf
|
|
60
|
+
gskills add my-skill --aicoding gemini
|
|
61
|
+
gskills add my-skill --aicoding opencode
|
|
62
|
+
gskills add my-skill --aicoding all
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
`--tool` 仍然保留为 `--aicoding` 的兼容别名。
|
|
66
|
+
|
|
67
|
+
## 切换远程源
|
|
68
|
+
|
|
69
|
+
使用其他仓库或分支:
|
|
70
|
+
|
|
71
|
+
```powershell
|
|
72
|
+
gskills list --source owner/repo --ref main
|
|
73
|
+
gskills add my-skill --source https://github.com/owner/repo.git --ref main
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
也可以通过环境变量覆盖默认值:
|
|
77
|
+
|
|
78
|
+
```powershell
|
|
79
|
+
$env:GSKILLS_SOURCE = "owner/repo"
|
|
80
|
+
$env:GSKILLS_REF = "main"
|
|
81
|
+
gskills list
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
## 仓库结构
|
|
85
|
+
|
|
86
|
+
```text
|
|
87
|
+
skills/ # 技能源码目录,每个技能一个文件夹
|
|
88
|
+
example-skill/
|
|
89
|
+
SKILL.md # 必需:包含 name 和 description frontmatter
|
|
90
|
+
scripts/ # 可选:可重复执行的脚本
|
|
91
|
+
references/ # 可选:按需加载的参考文档
|
|
92
|
+
assets/ # 可选:模板、图片、示例文件等资源
|
|
93
|
+
scripts/ # 仓库维护脚本
|
|
94
|
+
bin/ # 发布到 npm 的 gskills 命令
|
|
95
|
+
templates/ # 新技能模板
|
|
96
|
+
docs/ # 兼容性和设计文档
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
请把 `skills/` 作为唯一手写源码目录。发布到 npm 时,`package.json` 的 `files` 白名单会排除 `skills/`,确保安装包保持轻量。
|
|
100
|
+
|
|
101
|
+
## 创建新技能
|
|
102
|
+
|
|
103
|
+
```powershell
|
|
104
|
+
npm run new-skill -- my-skill --description "Use when doing a specific repeatable task." --resources references,scripts
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
该命令会创建 `skills/my-skill/SKILL.md`,并按需创建 `references/`、`scripts/`、`assets/` 等资源目录。
|
|
108
|
+
|
|
109
|
+
## 校验技能
|
|
110
|
+
|
|
111
|
+
```powershell
|
|
112
|
+
npm run validate
|
|
113
|
+
npm test
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
`npm run validate` 会校验技能目录命名和 `SKILL.md` frontmatter。`npm test` 会运行命令行工具和安装逻辑的自动化测试。
|
|
117
|
+
|
|
118
|
+
## 本地开发时安装技能
|
|
119
|
+
|
|
120
|
+
如果技能还没有推送到 GitHub `main`,`gskills add` 看不到它。此时可以使用本仓库的本地安装脚本:
|
|
121
|
+
|
|
122
|
+
```powershell
|
|
123
|
+
npm run install-skills -- --aicoding default --dest D:\path\to\project --skills my-skill
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
安装到所有支持目标:
|
|
127
|
+
|
|
128
|
+
```powershell
|
|
129
|
+
npm run install-skills -- --aicoding all --dest D:\path\to\project --skills my-skill,another-skill
|
|
130
|
+
```
|
|
131
|
+
|
|
132
|
+
安装到当前仓库用于本地测试:
|
|
133
|
+
|
|
134
|
+
```powershell
|
|
135
|
+
npm run install-skills -- --aicoding default --dest . --skills code-review-loop
|
|
136
|
+
```
|
|
137
|
+
|
|
138
|
+
兼容旧用法时仍可使用 `--tool`,但新文档和示例优先使用 `--aicoding`。
|
|
139
|
+
|
|
140
|
+
发布后的用户使用方式仍然推荐:
|
|
141
|
+
|
|
142
|
+
```powershell
|
|
143
|
+
gskills add my-skill
|
|
144
|
+
gskills remove my-skill
|
|
145
|
+
```
|
|
146
|
+
|
|
147
|
+
## 发布前检查
|
|
148
|
+
|
|
149
|
+
```powershell
|
|
150
|
+
npm test
|
|
151
|
+
npm run validate
|
|
152
|
+
npm pack --dry-run
|
|
153
|
+
```
|
|
154
|
+
|
|
155
|
+
`npm pack --dry-run` 的输出中不应包含 `skills/`。
|
package/bin/gskills.mjs
ADDED
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
|
|
4
|
+
import {
|
|
5
|
+
getCliAICodingTarget,
|
|
6
|
+
promptAICodingTarget,
|
|
7
|
+
renderAICodingChoices
|
|
8
|
+
} from "../scripts/lib/aicoding-select.mjs";
|
|
9
|
+
import { listAICodingTargets, parseCliArgs } from "../scripts/lib/skill-utils.mjs";
|
|
10
|
+
import {
|
|
11
|
+
DEFAULT_REF,
|
|
12
|
+
DEFAULT_SOURCE,
|
|
13
|
+
addRemoteSkills,
|
|
14
|
+
listRemoteSkills,
|
|
15
|
+
removeInstalledSkills,
|
|
16
|
+
resolveRemoteConfig
|
|
17
|
+
} from "../scripts/lib/remote-skills.mjs";
|
|
18
|
+
|
|
19
|
+
async function main() {
|
|
20
|
+
const [command = "help", ...rest] = process.argv.slice(2);
|
|
21
|
+
const args = parseCliArgs(rest);
|
|
22
|
+
|
|
23
|
+
if (command === "help" || command === "--help" || command === "-h") {
|
|
24
|
+
printHelp();
|
|
25
|
+
return;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
if (command === "tools" || command === "aicodings" || command === "targets") {
|
|
29
|
+
printAICodings();
|
|
30
|
+
return;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
if (command === "list") {
|
|
34
|
+
const config = resolveRemoteConfig({ source: args.source, ref: args.ref });
|
|
35
|
+
const skills = await listRemoteSkills(config);
|
|
36
|
+
if (skills.length === 0) {
|
|
37
|
+
console.log("No remote skills found.");
|
|
38
|
+
return;
|
|
39
|
+
}
|
|
40
|
+
for (const skill of skills) {
|
|
41
|
+
console.log(`${skill.name} - ${skill.description}`);
|
|
42
|
+
}
|
|
43
|
+
return;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
if (command === "add") {
|
|
47
|
+
const target = getCliAICodingTarget(args);
|
|
48
|
+
const aicoding = target || (await promptAICodingTarget());
|
|
49
|
+
const result = await addRemoteSkills({
|
|
50
|
+
source: args.source,
|
|
51
|
+
ref: args.ref,
|
|
52
|
+
destDir: path.resolve(args.dest || "."),
|
|
53
|
+
tool: aicoding,
|
|
54
|
+
skills: args._
|
|
55
|
+
});
|
|
56
|
+
for (const entry of result.installed) {
|
|
57
|
+
console.log(`Added ${entry.skillName} for ${entry.tool}: ${entry.path}`);
|
|
58
|
+
}
|
|
59
|
+
return;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
if (command === "remove" || command === "rm") {
|
|
63
|
+
const target = getCliAICodingTarget(args);
|
|
64
|
+
const aicoding = target || (await promptAICodingTarget());
|
|
65
|
+
const result = await removeInstalledSkills({
|
|
66
|
+
destDir: path.resolve(args.dest || "."),
|
|
67
|
+
tool: aicoding,
|
|
68
|
+
skills: args._
|
|
69
|
+
});
|
|
70
|
+
for (const entry of result.removed) {
|
|
71
|
+
console.log(`Removed ${entry.skillName} for ${entry.tool}: ${entry.path}`);
|
|
72
|
+
}
|
|
73
|
+
return;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
throw new Error(`Unknown command "${command}". Run gskills help.`);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function printAICodings() {
|
|
80
|
+
console.log(renderAICodingChoices());
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function printHelp() {
|
|
84
|
+
console.log(`gskills
|
|
85
|
+
|
|
86
|
+
Usage:
|
|
87
|
+
gskills list [--source owner/repo] [--ref main]
|
|
88
|
+
gskills add <skill...> [--aicoding target] [--dest path]
|
|
89
|
+
gskills remove <skill...> [--aicoding target] [--dest path]
|
|
90
|
+
gskills aicodings
|
|
91
|
+
|
|
92
|
+
Defaults:
|
|
93
|
+
source: ${DEFAULT_SOURCE}
|
|
94
|
+
ref: ${DEFAULT_REF}
|
|
95
|
+
aicoding: interactive selection in a TTY, default in scripts
|
|
96
|
+
|
|
97
|
+
Targets:
|
|
98
|
+
${listAICodingTargets()
|
|
99
|
+
.map((target) => ` ${target.name} -> ${target.relativePath}`)
|
|
100
|
+
.join("\n")}
|
|
101
|
+
|
|
102
|
+
Compatibility:
|
|
103
|
+
--tool works as an alias for --aicoding
|
|
104
|
+
|
|
105
|
+
Environment:
|
|
106
|
+
GSKILLS_SOURCE=owner/repo
|
|
107
|
+
GSKILLS_REF=main`);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
main().catch((error) => {
|
|
111
|
+
console.error(error.message);
|
|
112
|
+
process.exitCode = 1;
|
|
113
|
+
});
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
# Tool Compatibility
|
|
2
|
+
|
|
3
|
+
This repository uses `skills/` as the canonical source and installs copies into project-local directories expected by common coding tools.
|
|
4
|
+
|
|
5
|
+
When used through npm, `gskills` fetches `skills/` from `zhy15608103017/generalSkills@main` by default. Override the source with `--source owner/repo` or `GSKILLS_SOURCE`; override the ref with `--ref` or `GSKILLS_REF`.
|
|
6
|
+
|
|
7
|
+
Run `gskills aicodings` to list supported AI coding targets. If `gskills add <skill>` runs in an interactive terminal without `--aicoding`, it opens a selection list. In non-interactive environments, it uses `default`, which writes to `.agents/skills`.
|
|
8
|
+
|
|
9
|
+
## Supported Targets
|
|
10
|
+
|
|
11
|
+
| Target | Directory |
|
|
12
|
+
| --- | --- |
|
|
13
|
+
| `default` | `.agents/skills` |
|
|
14
|
+
| `codex` | `.agents/skills` |
|
|
15
|
+
| `claude` / `claude-code` | `.claude/skills` |
|
|
16
|
+
| `cursor` | `.cursor/skills` |
|
|
17
|
+
| `trae` | `.trae/skills` |
|
|
18
|
+
| `windsurf` / `cascade` | `.windsurf/skills` |
|
|
19
|
+
| `gemini` / `gemini-cli` | `.gemini/skills` |
|
|
20
|
+
| `opencode` | `.opencode/skills` |
|
|
21
|
+
|
|
22
|
+
## Codex
|
|
23
|
+
|
|
24
|
+
Use `.agents/skills` in the target project for Codex-style project skills. Run:
|
|
25
|
+
|
|
26
|
+
```powershell
|
|
27
|
+
npx general-skills add my-skill --aicoding codex
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
For local repository development:
|
|
31
|
+
|
|
32
|
+
```powershell
|
|
33
|
+
npm run install-skills -- --aicoding codex --dest D:\path\to\project
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
## Claude Code
|
|
37
|
+
|
|
38
|
+
Use `.claude/skills` in the target project for Claude Code project skills. Run:
|
|
39
|
+
|
|
40
|
+
```powershell
|
|
41
|
+
npx general-skills add my-skill --aicoding claude
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
## Cursor
|
|
45
|
+
|
|
46
|
+
Use `.cursor/skills` in the target project for Cursor project skills. Run:
|
|
47
|
+
|
|
48
|
+
```powershell
|
|
49
|
+
npx general-skills add my-skill --aicoding cursor
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
## Trae
|
|
53
|
+
|
|
54
|
+
Use `.trae/skills` in the target project for Trae project skills. Run:
|
|
55
|
+
|
|
56
|
+
```powershell
|
|
57
|
+
npx general-skills add my-skill --aicoding trae
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
For local repository development:
|
|
61
|
+
|
|
62
|
+
```powershell
|
|
63
|
+
npm run install-skills -- --aicoding trae --dest D:\path\to\project
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
## opencode
|
|
67
|
+
|
|
68
|
+
Use `.opencode/skills` in the target project for opencode project skills. Run:
|
|
69
|
+
|
|
70
|
+
```powershell
|
|
71
|
+
npx general-skills add my-skill --aicoding opencode
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
## Windsurf
|
|
75
|
+
|
|
76
|
+
Use `.windsurf/skills` in the target project for Windsurf/Cascade project skills. Run:
|
|
77
|
+
|
|
78
|
+
```powershell
|
|
79
|
+
npx general-skills add my-skill --aicoding windsurf
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
## Gemini CLI
|
|
83
|
+
|
|
84
|
+
Use `.gemini/skills` in the target project for Gemini CLI project skills. Run:
|
|
85
|
+
|
|
86
|
+
```powershell
|
|
87
|
+
npx general-skills add my-skill --aicoding gemini
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
For local repository development:
|
|
91
|
+
|
|
92
|
+
```powershell
|
|
93
|
+
npm run install-skills -- --aicoding gemini --dest D:\path\to\project
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
## Multi-Tool Install
|
|
97
|
+
|
|
98
|
+
Use `--aicoding all` when a project is used by multiple coding tools:
|
|
99
|
+
|
|
100
|
+
```powershell
|
|
101
|
+
npx general-skills add my-skill --aicoding all
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
For local repository development:
|
|
105
|
+
|
|
106
|
+
```powershell
|
|
107
|
+
npm run install-skills -- --aicoding all --dest D:\path\to\project
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
The install command replaces generated copies of each selected skill, while leaving unrelated target project files alone.
|
package/package.json
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "general-skills",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "A lightweight CLI for installing reusable Agent Skills into AI coding tools.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"repository": {
|
|
8
|
+
"type": "git",
|
|
9
|
+
"url": "git+https://github.com/zhy15608103017/generalSkills.git"
|
|
10
|
+
},
|
|
11
|
+
"keywords": [
|
|
12
|
+
"agent-skills",
|
|
13
|
+
"codex",
|
|
14
|
+
"claude-code",
|
|
15
|
+
"cursor",
|
|
16
|
+
"trae",
|
|
17
|
+
"windsurf",
|
|
18
|
+
"gemini-cli",
|
|
19
|
+
"opencode",
|
|
20
|
+
"cli"
|
|
21
|
+
],
|
|
22
|
+
"bin": {
|
|
23
|
+
"gskills": "bin/gskills.mjs"
|
|
24
|
+
},
|
|
25
|
+
"files": [
|
|
26
|
+
"bin/",
|
|
27
|
+
"scripts/",
|
|
28
|
+
"templates/",
|
|
29
|
+
"README.md",
|
|
30
|
+
"docs/compatibility.md"
|
|
31
|
+
],
|
|
32
|
+
"scripts": {
|
|
33
|
+
"test": "node --test tests",
|
|
34
|
+
"validate": "node scripts/validate-skills.mjs",
|
|
35
|
+
"new-skill": "node scripts/new-skill.mjs",
|
|
36
|
+
"install-skills": "node scripts/install-skills.mjs",
|
|
37
|
+
"gskills": "node bin/gskills.mjs"
|
|
38
|
+
},
|
|
39
|
+
"engines": {
|
|
40
|
+
"node": ">=20"
|
|
41
|
+
}
|
|
42
|
+
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
import { pathToFileURL } from "node:url";
|
|
3
|
+
|
|
4
|
+
import {
|
|
5
|
+
copyDirectory,
|
|
6
|
+
listSkillDirs,
|
|
7
|
+
parseCliArgs,
|
|
8
|
+
resolveToolTargets,
|
|
9
|
+
validateSkillDir,
|
|
10
|
+
writeTargetConfig
|
|
11
|
+
} from "./lib/skill-utils.mjs";
|
|
12
|
+
|
|
13
|
+
export async function installSkills({
|
|
14
|
+
repoDir = process.cwd(),
|
|
15
|
+
destDir = process.cwd(),
|
|
16
|
+
tool = "all",
|
|
17
|
+
skills
|
|
18
|
+
} = {}) {
|
|
19
|
+
const selectedNames = skills ? new Set(skills) : null;
|
|
20
|
+
const skillDirs = (await listSkillDirs(repoDir)).filter(
|
|
21
|
+
(skill) => !selectedNames || selectedNames.has(skill.name)
|
|
22
|
+
);
|
|
23
|
+
|
|
24
|
+
if (selectedNames && skillDirs.length !== selectedNames.size) {
|
|
25
|
+
const found = new Set(skillDirs.map((skill) => skill.name));
|
|
26
|
+
const missing = [...selectedNames].filter((name) => !found.has(name));
|
|
27
|
+
throw new Error(`Missing skill(s): ${missing.join(", ")}`);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
for (const skillDir of skillDirs) {
|
|
31
|
+
const validation = await validateSkillDir(skillDir);
|
|
32
|
+
if (validation.errors.length > 0) {
|
|
33
|
+
throw new Error(validation.errors.join("\n"));
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const targets = resolveToolTargets(tool);
|
|
38
|
+
const installed = [];
|
|
39
|
+
for (const target of targets) {
|
|
40
|
+
for (const skillDir of skillDirs) {
|
|
41
|
+
const destination = path.join(destDir, target.relativePath, skillDir.name);
|
|
42
|
+
await copyDirectory(skillDir.path, destination);
|
|
43
|
+
await writeTargetConfig({ destDir, target });
|
|
44
|
+
installed.push({
|
|
45
|
+
tool: target.tool,
|
|
46
|
+
skillName: skillDir.name,
|
|
47
|
+
path: destination
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
return { installed };
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
async function main() {
|
|
56
|
+
const args = parseCliArgs(process.argv.slice(2));
|
|
57
|
+
const skills = args.skills ? String(args.skills).split(",").map((name) => name.trim()) : undefined;
|
|
58
|
+
const result = await installSkills({
|
|
59
|
+
repoDir: path.resolve(args.repo || "."),
|
|
60
|
+
destDir: path.resolve(args.dest || "."),
|
|
61
|
+
tool: args.aicoding || args.tool || "all",
|
|
62
|
+
skills
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
for (const entry of result.installed) {
|
|
66
|
+
console.log(`Installed ${entry.skillName} for ${entry.tool}: ${entry.path}`);
|
|
67
|
+
}
|
|
68
|
+
if (result.installed.length === 0) {
|
|
69
|
+
console.log("No skills to install.");
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
if (import.meta.url === pathToFileURL(process.argv[1] || "").href) {
|
|
74
|
+
main().catch((error) => {
|
|
75
|
+
console.error(error.message);
|
|
76
|
+
process.exitCode = 1;
|
|
77
|
+
});
|
|
78
|
+
}
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import readline from "node:readline";
|
|
2
|
+
|
|
3
|
+
import { listAICodingTargets } from "./skill-utils.mjs";
|
|
4
|
+
|
|
5
|
+
export function getCliAICodingTarget(args, { isTTY = process.stdin.isTTY } = {}) {
|
|
6
|
+
if (args.aicoding) return args.aicoding;
|
|
7
|
+
if (args.tool) return args.tool;
|
|
8
|
+
return isTTY ? null : "default";
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function renderAICodingChoices() {
|
|
12
|
+
return listAICodingTargets()
|
|
13
|
+
.map((target) => {
|
|
14
|
+
const aliases = target.aliases.length > 0 ? ` aliases: ${target.aliases.join(", ")}` : "";
|
|
15
|
+
return `${target.name} - ${target.label} -> ${target.relativePath}${aliases}`;
|
|
16
|
+
})
|
|
17
|
+
.join("\n");
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export async function promptAICodingTarget({
|
|
21
|
+
stdin = process.stdin,
|
|
22
|
+
stdout = process.stdout
|
|
23
|
+
} = {}) {
|
|
24
|
+
if (!stdin.isTTY) {
|
|
25
|
+
return "default";
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const choices = listAICodingTargets();
|
|
29
|
+
let selectedIndex = 0;
|
|
30
|
+
|
|
31
|
+
readline.emitKeypressEvents(stdin);
|
|
32
|
+
const previousRawMode = stdin.isRaw;
|
|
33
|
+
if (typeof stdin.setRawMode === "function") {
|
|
34
|
+
stdin.setRawMode(true);
|
|
35
|
+
}
|
|
36
|
+
stdin.resume();
|
|
37
|
+
|
|
38
|
+
return await new Promise((resolve, reject) => {
|
|
39
|
+
const render = () => {
|
|
40
|
+
stdout.write("\x1b[2J\x1b[0f");
|
|
41
|
+
stdout.write("Select AI coding target:\n\n");
|
|
42
|
+
choices.forEach((choice, index) => {
|
|
43
|
+
const marker = index === selectedIndex ? "> " : " ";
|
|
44
|
+
stdout.write(`${marker}${choice.name} - ${choice.label} (${choice.relativePath})\n`);
|
|
45
|
+
});
|
|
46
|
+
stdout.write("\nUse Up/Down, then Enter.\n");
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
const cleanup = () => {
|
|
50
|
+
stdin.off("keypress", onKeypress);
|
|
51
|
+
if (typeof stdin.setRawMode === "function") {
|
|
52
|
+
stdin.setRawMode(Boolean(previousRawMode));
|
|
53
|
+
}
|
|
54
|
+
stdin.pause();
|
|
55
|
+
stdout.write("\n");
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
const onKeypress = (_text, key) => {
|
|
59
|
+
if (key?.name === "down") {
|
|
60
|
+
selectedIndex = (selectedIndex + 1) % choices.length;
|
|
61
|
+
render();
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
64
|
+
if (key?.name === "up") {
|
|
65
|
+
selectedIndex = (selectedIndex - 1 + choices.length) % choices.length;
|
|
66
|
+
render();
|
|
67
|
+
return;
|
|
68
|
+
}
|
|
69
|
+
if (key?.name === "return" || key?.name === "enter") {
|
|
70
|
+
const selected = choices[selectedIndex].name;
|
|
71
|
+
cleanup();
|
|
72
|
+
resolve(selected);
|
|
73
|
+
return;
|
|
74
|
+
}
|
|
75
|
+
if (key?.ctrl && key.name === "c") {
|
|
76
|
+
cleanup();
|
|
77
|
+
reject(new Error("Cancelled."));
|
|
78
|
+
}
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
stdin.on("keypress", onKeypress);
|
|
82
|
+
render();
|
|
83
|
+
});
|
|
84
|
+
}
|
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
|
|
2
|
+
import os from "node:os";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
|
|
5
|
+
import { installSkills } from "../install-skills.mjs";
|
|
6
|
+
import {
|
|
7
|
+
parseSkillFrontmatter,
|
|
8
|
+
resolveToolTargets,
|
|
9
|
+
validateSkillName
|
|
10
|
+
} from "./skill-utils.mjs";
|
|
11
|
+
|
|
12
|
+
export const DEFAULT_SOURCE = "zhy15608103017/generalSkills";
|
|
13
|
+
export const DEFAULT_REF = "main";
|
|
14
|
+
|
|
15
|
+
export function resolveRemoteConfig({
|
|
16
|
+
source,
|
|
17
|
+
ref,
|
|
18
|
+
env = process.env
|
|
19
|
+
} = {}) {
|
|
20
|
+
return {
|
|
21
|
+
source: source || env.GSKILLS_SOURCE || DEFAULT_SOURCE,
|
|
22
|
+
ref: ref || env.GSKILLS_REF || DEFAULT_REF
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function parseGitHubSource(source) {
|
|
27
|
+
const value = String(source || "").trim();
|
|
28
|
+
const https = value.match(/^https:\/\/github\.com\/([^/\s]+)\/([^/\s]+?)(?:\.git)?\/?$/);
|
|
29
|
+
if (https) {
|
|
30
|
+
return {
|
|
31
|
+
owner: https[1],
|
|
32
|
+
repo: stripGitSuffix(https[2])
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const ssh = value.match(/^git@github\.com:([^/\s]+)\/([^/\s]+?)(?:\.git)?$/);
|
|
37
|
+
if (ssh) {
|
|
38
|
+
return {
|
|
39
|
+
owner: ssh[1],
|
|
40
|
+
repo: stripGitSuffix(ssh[2])
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const ownerRepo = value.match(/^([^/:\s]+)\/([^/:\s]+)$/);
|
|
45
|
+
if (ownerRepo) {
|
|
46
|
+
return {
|
|
47
|
+
owner: ownerRepo[1],
|
|
48
|
+
repo: stripGitSuffix(ownerRepo[2])
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
throw new Error(`Invalid GitHub source "${source}". Use owner/repo or a GitHub URL.`);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function stripGitSuffix(value) {
|
|
56
|
+
return value.endsWith(".git") ? value.slice(0, -4) : value;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export async function listRemoteSkills({
|
|
60
|
+
source,
|
|
61
|
+
ref,
|
|
62
|
+
fetchImpl = globalThis.fetch
|
|
63
|
+
} = {}) {
|
|
64
|
+
const config = resolveRemoteConfig({ source, ref });
|
|
65
|
+
const tree = await fetchRemoteTree({ ...config, fetchImpl });
|
|
66
|
+
const skillNames = discoverSkillNames(tree);
|
|
67
|
+
const skills = [];
|
|
68
|
+
|
|
69
|
+
for (const skillName of skillNames) {
|
|
70
|
+
const text = await fetchRawText({
|
|
71
|
+
...config,
|
|
72
|
+
remotePath: `skills/${skillName}/SKILL.md`,
|
|
73
|
+
fetchImpl
|
|
74
|
+
});
|
|
75
|
+
const frontmatter = parseSkillFrontmatter(text);
|
|
76
|
+
skills.push({
|
|
77
|
+
name: frontmatter.name,
|
|
78
|
+
description: frontmatter.description
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
return skills.sort((a, b) => a.name.localeCompare(b.name));
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export async function addRemoteSkills({
|
|
86
|
+
source,
|
|
87
|
+
ref,
|
|
88
|
+
destDir = process.cwd(),
|
|
89
|
+
tool = "all",
|
|
90
|
+
skills,
|
|
91
|
+
fetchImpl = globalThis.fetch
|
|
92
|
+
} = {}) {
|
|
93
|
+
const requestedSkills = normalizeRequestedSkills(skills);
|
|
94
|
+
const config = resolveRemoteConfig({ source, ref });
|
|
95
|
+
const tree = await fetchRemoteTree({ ...config, fetchImpl });
|
|
96
|
+
const available = new Set(discoverSkillNames(tree));
|
|
97
|
+
const missing = requestedSkills.filter((skill) => !available.has(skill));
|
|
98
|
+
if (missing.length > 0) {
|
|
99
|
+
throw new Error(`Missing remote skill(s): ${missing.join(", ")}`);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const tempRepo = await makeTempRepo();
|
|
103
|
+
try {
|
|
104
|
+
for (const skillName of requestedSkills) {
|
|
105
|
+
await downloadRemoteSkill({
|
|
106
|
+
...config,
|
|
107
|
+
tree,
|
|
108
|
+
skillName,
|
|
109
|
+
repoDir: tempRepo,
|
|
110
|
+
fetchImpl
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
return await installSkills({
|
|
115
|
+
repoDir: tempRepo,
|
|
116
|
+
destDir,
|
|
117
|
+
tool,
|
|
118
|
+
skills: requestedSkills
|
|
119
|
+
});
|
|
120
|
+
} finally {
|
|
121
|
+
await rm(tempRepo, { recursive: true, force: true });
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
export async function removeInstalledSkills({
|
|
126
|
+
destDir = process.cwd(),
|
|
127
|
+
tool = "all",
|
|
128
|
+
skills
|
|
129
|
+
} = {}) {
|
|
130
|
+
const requestedSkills = normalizeRequestedSkills(skills);
|
|
131
|
+
const targets = resolveToolTargets(tool);
|
|
132
|
+
const removed = [];
|
|
133
|
+
|
|
134
|
+
for (const target of targets) {
|
|
135
|
+
for (const skillName of requestedSkills) {
|
|
136
|
+
const destination = path.join(destDir, target.relativePath, skillName);
|
|
137
|
+
await rm(destination, { recursive: true, force: true });
|
|
138
|
+
removed.push({
|
|
139
|
+
tool: target.tool,
|
|
140
|
+
skillName,
|
|
141
|
+
path: destination
|
|
142
|
+
});
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
return { removed };
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
async function fetchRemoteTree({ source, ref, fetchImpl }) {
|
|
150
|
+
ensureFetch(fetchImpl);
|
|
151
|
+
const { owner, repo } = parseGitHubSource(source);
|
|
152
|
+
const url = `https://api.github.com/repos/${owner}/${repo}/git/trees/${encodeURIComponent(ref)}?recursive=1`;
|
|
153
|
+
const response = await fetchImpl(url, {
|
|
154
|
+
headers: {
|
|
155
|
+
"Accept": "application/vnd.github+json",
|
|
156
|
+
"User-Agent": "gskills"
|
|
157
|
+
}
|
|
158
|
+
});
|
|
159
|
+
if (!response.ok) {
|
|
160
|
+
throw new Error(`Failed to fetch remote skill tree: HTTP ${response.status} ${response.statusText}`);
|
|
161
|
+
}
|
|
162
|
+
const data = await response.json();
|
|
163
|
+
if (!Array.isArray(data.tree)) {
|
|
164
|
+
throw new Error("GitHub tree response did not include a tree array.");
|
|
165
|
+
}
|
|
166
|
+
return data.tree;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function discoverSkillNames(tree) {
|
|
170
|
+
return tree
|
|
171
|
+
.filter((entry) => entry.type === "blob")
|
|
172
|
+
.map((entry) => entry.path)
|
|
173
|
+
.map((remotePath) => remotePath.match(/^skills\/([^/]+)\/SKILL\.md$/))
|
|
174
|
+
.filter(Boolean)
|
|
175
|
+
.map((match) => match[1])
|
|
176
|
+
.filter((name) => validateSkillName(name).length === 0)
|
|
177
|
+
.sort((a, b) => a.localeCompare(b));
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
async function downloadRemoteSkill({
|
|
181
|
+
source,
|
|
182
|
+
ref,
|
|
183
|
+
tree,
|
|
184
|
+
skillName,
|
|
185
|
+
repoDir,
|
|
186
|
+
fetchImpl
|
|
187
|
+
}) {
|
|
188
|
+
const prefix = `skills/${skillName}/`;
|
|
189
|
+
const files = tree
|
|
190
|
+
.filter((entry) => entry.type === "blob")
|
|
191
|
+
.map((entry) => entry.path)
|
|
192
|
+
.filter((remotePath) => remotePath.startsWith(prefix));
|
|
193
|
+
|
|
194
|
+
for (const remotePath of files) {
|
|
195
|
+
const bytes = await fetchRawBytes({
|
|
196
|
+
source,
|
|
197
|
+
ref,
|
|
198
|
+
remotePath,
|
|
199
|
+
fetchImpl
|
|
200
|
+
});
|
|
201
|
+
const outputPath = path.join(repoDir, ...remotePath.split("/"));
|
|
202
|
+
await mkdir(path.dirname(outputPath), { recursive: true });
|
|
203
|
+
await writeFile(outputPath, bytes);
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
async function fetchRawText(options) {
|
|
208
|
+
const bytes = await fetchRawBytes(options);
|
|
209
|
+
return bytes.toString("utf8");
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
async function fetchRawBytes({ source, ref, remotePath, fetchImpl }) {
|
|
213
|
+
ensureFetch(fetchImpl);
|
|
214
|
+
const { owner, repo } = parseGitHubSource(source);
|
|
215
|
+
const url = rawGitHubUrl({ owner, repo, ref, remotePath });
|
|
216
|
+
const response = await fetchImpl(url, {
|
|
217
|
+
headers: {
|
|
218
|
+
"User-Agent": "gskills"
|
|
219
|
+
}
|
|
220
|
+
});
|
|
221
|
+
if (!response.ok) {
|
|
222
|
+
throw new Error(`Failed to fetch ${remotePath}: HTTP ${response.status} ${response.statusText}`);
|
|
223
|
+
}
|
|
224
|
+
if (typeof response.arrayBuffer === "function") {
|
|
225
|
+
return Buffer.from(await response.arrayBuffer());
|
|
226
|
+
}
|
|
227
|
+
return Buffer.from(await response.text(), "utf8");
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
function rawGitHubUrl({ owner, repo, ref, remotePath }) {
|
|
231
|
+
const encodedPath = remotePath.split("/").map(encodeURIComponent).join("/");
|
|
232
|
+
return `https://raw.githubusercontent.com/${owner}/${repo}/${encodeURIComponent(ref)}/${encodedPath}`;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
function normalizeRequestedSkills(skills) {
|
|
236
|
+
const requested = Array.isArray(skills) ? skills : String(skills || "").split(",");
|
|
237
|
+
const normalized = requested.map((skill) => String(skill).trim()).filter(Boolean);
|
|
238
|
+
if (normalized.length === 0) {
|
|
239
|
+
throw new Error("At least one skill name is required.");
|
|
240
|
+
}
|
|
241
|
+
const errors = normalized.flatMap((skill) => validateSkillName(skill).map((error) => `${skill}: ${error}`));
|
|
242
|
+
if (errors.length > 0) {
|
|
243
|
+
throw new Error(errors.join("\n"));
|
|
244
|
+
}
|
|
245
|
+
return [...new Set(normalized)];
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
async function makeTempRepo() {
|
|
249
|
+
return await mkdtemp(path.join(os.tmpdir(), "gskills-"));
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
function ensureFetch(fetchImpl) {
|
|
253
|
+
if (typeof fetchImpl !== "function") {
|
|
254
|
+
throw new Error("fetch is unavailable. Use Node.js 20 or newer.");
|
|
255
|
+
}
|
|
256
|
+
}
|
|
@@ -0,0 +1,300 @@
|
|
|
1
|
+
import { cp, mkdir, readdir, readFile, rm, stat, writeFile } from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
|
|
4
|
+
export const AI_CODING_TARGETS = [
|
|
5
|
+
{
|
|
6
|
+
name: "default",
|
|
7
|
+
label: "Default / Agent Skills",
|
|
8
|
+
relativePath: ".agents/skills",
|
|
9
|
+
aliases: ["agent", "agents"]
|
|
10
|
+
},
|
|
11
|
+
{
|
|
12
|
+
name: "codex",
|
|
13
|
+
label: "Codex",
|
|
14
|
+
relativePath: ".agents/skills",
|
|
15
|
+
aliases: ["openai-codex"]
|
|
16
|
+
},
|
|
17
|
+
{
|
|
18
|
+
name: "claude",
|
|
19
|
+
label: "Claude Code",
|
|
20
|
+
relativePath: ".claude/skills",
|
|
21
|
+
aliases: ["claude-code"]
|
|
22
|
+
},
|
|
23
|
+
{
|
|
24
|
+
name: "cursor",
|
|
25
|
+
label: "Cursor",
|
|
26
|
+
relativePath: ".cursor/skills",
|
|
27
|
+
aliases: []
|
|
28
|
+
},
|
|
29
|
+
{
|
|
30
|
+
name: "gemini",
|
|
31
|
+
label: "Gemini CLI",
|
|
32
|
+
relativePath: ".gemini/skills",
|
|
33
|
+
aliases: ["gemini-cli"]
|
|
34
|
+
},
|
|
35
|
+
{
|
|
36
|
+
name: "opencode",
|
|
37
|
+
label: "opencode",
|
|
38
|
+
relativePath: ".opencode/skills",
|
|
39
|
+
aliases: ["open-code"]
|
|
40
|
+
},
|
|
41
|
+
{
|
|
42
|
+
name: "trae",
|
|
43
|
+
label: "Trae",
|
|
44
|
+
relativePath: ".trae/skills",
|
|
45
|
+
aliases: ["trae-ai"]
|
|
46
|
+
},
|
|
47
|
+
{
|
|
48
|
+
name: "windsurf",
|
|
49
|
+
label: "Windsurf / Cascade",
|
|
50
|
+
relativePath: ".windsurf/skills",
|
|
51
|
+
aliases: ["cascade"]
|
|
52
|
+
}
|
|
53
|
+
];
|
|
54
|
+
|
|
55
|
+
export const TOOL_TARGETS = Object.fromEntries(
|
|
56
|
+
AI_CODING_TARGETS.filter((target) => target.name !== "default").map((target) => [
|
|
57
|
+
target.name,
|
|
58
|
+
target.relativePath
|
|
59
|
+
])
|
|
60
|
+
);
|
|
61
|
+
|
|
62
|
+
export function normalizeSkillName(value) {
|
|
63
|
+
return String(value ?? "")
|
|
64
|
+
.trim()
|
|
65
|
+
.toLowerCase()
|
|
66
|
+
.replace(/[^a-z0-9]+/g, "-")
|
|
67
|
+
.replace(/^-+|-+$/g, "")
|
|
68
|
+
.replace(/-{2,}/g, "-");
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export function validateSkillName(name) {
|
|
72
|
+
const errors = [];
|
|
73
|
+
if (!name) {
|
|
74
|
+
errors.push("Skill name is required.");
|
|
75
|
+
return errors;
|
|
76
|
+
}
|
|
77
|
+
if (name.length > 64) {
|
|
78
|
+
errors.push("Skill name must be 64 characters or fewer.");
|
|
79
|
+
}
|
|
80
|
+
if (!/^[a-z0-9][a-z0-9-]*[a-z0-9]$|^[a-z0-9]$/.test(name)) {
|
|
81
|
+
errors.push("Skill name must use lowercase letters, digits, and hyphens only.");
|
|
82
|
+
}
|
|
83
|
+
if (name.includes("--")) {
|
|
84
|
+
errors.push("Skill name must not contain repeated hyphens.");
|
|
85
|
+
}
|
|
86
|
+
return errors;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export function parseSkillFrontmatter(text) {
|
|
90
|
+
const lines = text.split(/\r?\n/);
|
|
91
|
+
if (lines[0] !== "---") {
|
|
92
|
+
throw new Error("SKILL.md must start with YAML frontmatter.");
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const endIndex = lines.findIndex((line, index) => index > 0 && line === "---");
|
|
96
|
+
if (endIndex === -1) {
|
|
97
|
+
throw new Error("SKILL.md frontmatter must end with a closing --- line.");
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const data = {};
|
|
101
|
+
for (const line of lines.slice(1, endIndex)) {
|
|
102
|
+
if (!line.trim()) continue;
|
|
103
|
+
const match = line.match(/^([A-Za-z0-9_-]+):\s*(.*)$/);
|
|
104
|
+
if (!match) {
|
|
105
|
+
throw new Error(`Invalid frontmatter line: ${line}`);
|
|
106
|
+
}
|
|
107
|
+
const [, key, rawValue] = match;
|
|
108
|
+
data[key] = stripYamlString(rawValue.trim());
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
if (!data.name) {
|
|
112
|
+
throw new Error("SKILL.md frontmatter must include name.");
|
|
113
|
+
}
|
|
114
|
+
if (!data.description) {
|
|
115
|
+
throw new Error("SKILL.md frontmatter must include description.");
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
return {
|
|
119
|
+
name: data.name,
|
|
120
|
+
description: data.description
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function stripYamlString(value) {
|
|
125
|
+
if (
|
|
126
|
+
(value.startsWith('"') && value.endsWith('"')) ||
|
|
127
|
+
(value.startsWith("'") && value.endsWith("'"))
|
|
128
|
+
) {
|
|
129
|
+
return value.slice(1, -1);
|
|
130
|
+
}
|
|
131
|
+
return value;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
export async function listSkillDirs(repoDir) {
|
|
135
|
+
const skillsDir = path.join(repoDir, "skills");
|
|
136
|
+
try {
|
|
137
|
+
const entries = await readdir(skillsDir, { withFileTypes: true });
|
|
138
|
+
return entries
|
|
139
|
+
.filter((entry) => entry.isDirectory())
|
|
140
|
+
.filter((entry) => !entry.name.startsWith("."))
|
|
141
|
+
.map((entry) => ({
|
|
142
|
+
name: entry.name,
|
|
143
|
+
path: path.join(skillsDir, entry.name)
|
|
144
|
+
}))
|
|
145
|
+
.sort((a, b) => a.name.localeCompare(b.name));
|
|
146
|
+
} catch (error) {
|
|
147
|
+
if (error.code === "ENOENT") return [];
|
|
148
|
+
throw error;
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
export async function validateSkillDir(skillDir) {
|
|
153
|
+
const errors = [];
|
|
154
|
+
const skillFile = path.join(skillDir.path, "SKILL.md");
|
|
155
|
+
|
|
156
|
+
for (const error of validateSkillName(skillDir.name)) {
|
|
157
|
+
errors.push(`${skillDir.name}: ${error}`);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
let frontmatter;
|
|
161
|
+
try {
|
|
162
|
+
const text = await readFile(skillFile, "utf8");
|
|
163
|
+
frontmatter = parseSkillFrontmatter(text);
|
|
164
|
+
} catch (error) {
|
|
165
|
+
errors.push(`${skillDir.name}: ${error.message}`);
|
|
166
|
+
return { skill: skillDir.name, errors };
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
if (frontmatter.name !== skillDir.name) {
|
|
170
|
+
errors.push(
|
|
171
|
+
`${skillDir.name}: frontmatter name must match folder name "${skillDir.name}".`
|
|
172
|
+
);
|
|
173
|
+
}
|
|
174
|
+
for (const error of validateSkillName(frontmatter.name)) {
|
|
175
|
+
errors.push(`${skillDir.name}: frontmatter ${error}`);
|
|
176
|
+
}
|
|
177
|
+
if (!frontmatter.description.trim()) {
|
|
178
|
+
errors.push(`${skillDir.name}: frontmatter description must not be empty.`);
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
return { skill: skillDir.name, errors };
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
export function resolveToolTargets(tool) {
|
|
185
|
+
const selected = String(tool ?? "default").toLowerCase();
|
|
186
|
+
const targets =
|
|
187
|
+
selected === "all"
|
|
188
|
+
? AI_CODING_TARGETS.filter((target) => target.name !== "default")
|
|
189
|
+
: [resolveSingleAICodingTarget(selected)];
|
|
190
|
+
|
|
191
|
+
return targets.map((target) => ({
|
|
192
|
+
tool: target.name,
|
|
193
|
+
label: target.label,
|
|
194
|
+
relativePath: target.relativePath
|
|
195
|
+
}));
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
export function resolveSingleAICodingTarget(value) {
|
|
199
|
+
const selected = String(value ?? "default").toLowerCase();
|
|
200
|
+
const target = AI_CODING_TARGETS.find(
|
|
201
|
+
(entry) => entry.name === selected || entry.aliases.includes(selected)
|
|
202
|
+
);
|
|
203
|
+
if (!target) {
|
|
204
|
+
const names = AI_CODING_TARGETS.map((entry) => entry.name).join(", ");
|
|
205
|
+
throw new Error(`Unsupported AI coding target "${value}". Use ${names}, or all.`);
|
|
206
|
+
}
|
|
207
|
+
return target;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
export function listAICodingTargets() {
|
|
211
|
+
return AI_CODING_TARGETS.map((target) => ({
|
|
212
|
+
name: target.name,
|
|
213
|
+
label: target.label,
|
|
214
|
+
relativePath: target.relativePath,
|
|
215
|
+
aliases: [...target.aliases]
|
|
216
|
+
}));
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
export async function writeTargetConfig({ destDir, target }) {
|
|
220
|
+
if (target.tool !== "gemini") {
|
|
221
|
+
return [];
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
const extensionDir = path.join(destDir, ".gemini", "extensions", "gskills");
|
|
225
|
+
await mkdir(extensionDir, { recursive: true });
|
|
226
|
+
const manifestPath = path.join(extensionDir, "gemini-extension.json");
|
|
227
|
+
const manifest = {
|
|
228
|
+
name: "gskills",
|
|
229
|
+
version: "0.1.0",
|
|
230
|
+
mcpServers: {}
|
|
231
|
+
};
|
|
232
|
+
await writeFile(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`, "utf8");
|
|
233
|
+
return [manifestPath];
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
export function parseResourceList(value) {
|
|
237
|
+
if (Array.isArray(value)) return value;
|
|
238
|
+
if (!value) return [];
|
|
239
|
+
const allowed = new Set(["scripts", "references", "assets"]);
|
|
240
|
+
return String(value)
|
|
241
|
+
.split(",")
|
|
242
|
+
.map((entry) => entry.trim())
|
|
243
|
+
.filter(Boolean)
|
|
244
|
+
.map((entry) => {
|
|
245
|
+
if (!allowed.has(entry)) {
|
|
246
|
+
throw new Error(`Unsupported resource "${entry}". Use scripts, references, or assets.`);
|
|
247
|
+
}
|
|
248
|
+
return entry;
|
|
249
|
+
});
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
export async function copyDirectory(source, destination) {
|
|
253
|
+
await rm(destination, { recursive: true, force: true });
|
|
254
|
+
await mkdir(path.dirname(destination), { recursive: true });
|
|
255
|
+
await cp(source, destination, {
|
|
256
|
+
recursive: true,
|
|
257
|
+
force: true,
|
|
258
|
+
errorOnExist: false
|
|
259
|
+
});
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
export async function pathExists(filePath) {
|
|
263
|
+
try {
|
|
264
|
+
await stat(filePath);
|
|
265
|
+
return true;
|
|
266
|
+
} catch (error) {
|
|
267
|
+
if (error.code === "ENOENT") return false;
|
|
268
|
+
throw error;
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
export function parseCliArgs(argv) {
|
|
273
|
+
const args = {
|
|
274
|
+
_: []
|
|
275
|
+
};
|
|
276
|
+
|
|
277
|
+
for (let index = 0; index < argv.length; index += 1) {
|
|
278
|
+
const arg = argv[index];
|
|
279
|
+
if (!arg.startsWith("--")) {
|
|
280
|
+
args._.push(arg);
|
|
281
|
+
continue;
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
const [key, inlineValue] = arg.slice(2).split(/=(.*)/s, 2);
|
|
285
|
+
if (inlineValue !== undefined && inlineValue !== "") {
|
|
286
|
+
args[key] = inlineValue;
|
|
287
|
+
continue;
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
const next = argv[index + 1];
|
|
291
|
+
if (next && !next.startsWith("--")) {
|
|
292
|
+
args[key] = next;
|
|
293
|
+
index += 1;
|
|
294
|
+
} else {
|
|
295
|
+
args[key] = true;
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
return args;
|
|
300
|
+
}
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import { mkdir, writeFile } from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { pathToFileURL } from "node:url";
|
|
4
|
+
|
|
5
|
+
import {
|
|
6
|
+
normalizeSkillName,
|
|
7
|
+
parseCliArgs,
|
|
8
|
+
parseResourceList,
|
|
9
|
+
pathExists,
|
|
10
|
+
validateSkillName
|
|
11
|
+
} from "./lib/skill-utils.mjs";
|
|
12
|
+
|
|
13
|
+
export async function createSkill({
|
|
14
|
+
repoDir = process.cwd(),
|
|
15
|
+
name,
|
|
16
|
+
description,
|
|
17
|
+
resources = [],
|
|
18
|
+
force = false
|
|
19
|
+
}) {
|
|
20
|
+
const skillName = normalizeSkillName(name);
|
|
21
|
+
const nameErrors = validateSkillName(skillName);
|
|
22
|
+
if (nameErrors.length > 0) {
|
|
23
|
+
throw new Error(nameErrors.join(" "));
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const skillDescription =
|
|
27
|
+
description?.trim() || `Use when working with ${skillName.replaceAll("-", " ")}.`;
|
|
28
|
+
const skillDir = path.join(repoDir, "skills", skillName);
|
|
29
|
+
if (!force && (await pathExists(skillDir))) {
|
|
30
|
+
throw new Error(`Skill "${skillName}" already exists. Use --force to replace it.`);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
await mkdir(skillDir, { recursive: true });
|
|
34
|
+
await writeFile(path.join(skillDir, "SKILL.md"), renderSkillMarkdown(skillName, skillDescription));
|
|
35
|
+
|
|
36
|
+
for (const resource of parseResourceList(resources)) {
|
|
37
|
+
await mkdir(path.join(skillDir, resource), { recursive: true });
|
|
38
|
+
await writeFile(path.join(skillDir, resource, ".gitkeep"), "");
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
return {
|
|
42
|
+
name: skillName,
|
|
43
|
+
path: skillDir
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function renderSkillMarkdown(name, description) {
|
|
48
|
+
return [
|
|
49
|
+
"---",
|
|
50
|
+
`name: ${name}`,
|
|
51
|
+
`description: ${description}`,
|
|
52
|
+
"---",
|
|
53
|
+
"",
|
|
54
|
+
`# ${titleCase(name)}`,
|
|
55
|
+
"",
|
|
56
|
+
"Use this skill when the request matches the frontmatter description.",
|
|
57
|
+
"",
|
|
58
|
+
"## Workflow",
|
|
59
|
+
"",
|
|
60
|
+
"1. Read any relevant files in this skill before acting.",
|
|
61
|
+
"2. Prefer bundled scripts for repeatable operations.",
|
|
62
|
+
"3. Keep outputs focused on the user's request.",
|
|
63
|
+
""
|
|
64
|
+
].join("\n");
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function titleCase(name) {
|
|
68
|
+
return name
|
|
69
|
+
.split("-")
|
|
70
|
+
.map((part) => part.charAt(0).toUpperCase() + part.slice(1))
|
|
71
|
+
.join(" ");
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
async function main() {
|
|
75
|
+
const args = parseCliArgs(process.argv.slice(2));
|
|
76
|
+
const name = args._[0];
|
|
77
|
+
if (!name) {
|
|
78
|
+
throw new Error(
|
|
79
|
+
"Usage: npm run new-skill -- <name> --description \"Use when ...\" --resources references,scripts"
|
|
80
|
+
);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const result = await createSkill({
|
|
84
|
+
repoDir: path.resolve(args.repo || "."),
|
|
85
|
+
name,
|
|
86
|
+
description: args.description,
|
|
87
|
+
resources: parseResourceList(args.resources),
|
|
88
|
+
force: Boolean(args.force)
|
|
89
|
+
});
|
|
90
|
+
console.log(`Created skill ${result.name} at ${result.path}`);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
if (import.meta.url === pathToFileURL(process.argv[1] || "").href) {
|
|
94
|
+
main().catch((error) => {
|
|
95
|
+
console.error(error.message);
|
|
96
|
+
process.exitCode = 1;
|
|
97
|
+
});
|
|
98
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
import { pathToFileURL } from "node:url";
|
|
3
|
+
|
|
4
|
+
import { listSkillDirs, parseCliArgs, validateSkillDir } from "./lib/skill-utils.mjs";
|
|
5
|
+
|
|
6
|
+
export async function validateRepository({ repoDir = process.cwd() } = {}) {
|
|
7
|
+
const skills = await listSkillDirs(repoDir);
|
|
8
|
+
const validations = await Promise.all(skills.map((skillDir) => validateSkillDir(skillDir)));
|
|
9
|
+
const errors = validations.flatMap((result) => result.errors);
|
|
10
|
+
|
|
11
|
+
return {
|
|
12
|
+
skills: skills.map((skill) => skill.name),
|
|
13
|
+
errors
|
|
14
|
+
};
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
async function main() {
|
|
18
|
+
const args = parseCliArgs(process.argv.slice(2));
|
|
19
|
+
const result = await validateRepository({
|
|
20
|
+
repoDir: path.resolve(args.repo || ".")
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
if (result.skills.length === 0) {
|
|
24
|
+
console.log("No skills found under skills/. Repository scaffold is valid.");
|
|
25
|
+
} else {
|
|
26
|
+
console.log(`Validated ${result.skills.length} skill(s).`);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
if (result.errors.length > 0) {
|
|
30
|
+
for (const error of result.errors) {
|
|
31
|
+
console.error(`- ${error}`);
|
|
32
|
+
}
|
|
33
|
+
process.exitCode = 1;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
if (import.meta.url === pathToFileURL(process.argv[1] || "").href) {
|
|
38
|
+
main().catch((error) => {
|
|
39
|
+
console.error(error.message);
|
|
40
|
+
process.exitCode = 1;
|
|
41
|
+
});
|
|
42
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: {{name}}
|
|
3
|
+
description: {{description}}
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# {{title}}
|
|
7
|
+
|
|
8
|
+
Use this skill when the request matches the frontmatter description.
|
|
9
|
+
|
|
10
|
+
## Workflow
|
|
11
|
+
|
|
12
|
+
1. Read any relevant files in this skill before acting.
|
|
13
|
+
2. Prefer bundled scripts for repeatable operations.
|
|
14
|
+
3. Keep outputs focused on the user's request.
|