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 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/`。
@@ -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.