@sstar/skill-install 1.2.2 → 1.2.3
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
CHANGED
|
@@ -4,9 +4,9 @@
|
|
|
4
4
|
|
|
5
5
|
## 功能特性
|
|
6
6
|
|
|
7
|
-
- **多工具支持**:同时支持 Claude Code (`~/.claude/skills/`) 和 Codex (`~/.
|
|
7
|
+
- **多工具支持**:同时支持 Claude Code (`~/.claude/skills/`) 和 Codex (`~/.agents/skills/`)
|
|
8
8
|
- **自动检测来源**:自动识别来源类型(公开 URL、需认证的 Wiki URL 或本地文件)
|
|
9
|
-
- **多种归档格式**:支持 `.zip
|
|
9
|
+
- **多种归档格式**:支持 `.zip`、`.skill`、`.tar.gz`、`.tgz`,并优先按文件内容识别
|
|
10
10
|
- **严格验证**:验证归档包含有效的 `SKILL.md` 文件及必需的 frontmatter
|
|
11
11
|
- **Wiki 认证**:支持内部 Confluence Wiki 的身份认证
|
|
12
12
|
- **技能管理**:列出已安装的技能并支持卸载
|
|
@@ -90,8 +90,8 @@ skill-install install ./my-skill.zip --force
|
|
|
90
90
|
| 选项 | 描述 |
|
|
91
91
|
|------|------|
|
|
92
92
|
| `<source>` | 技能归档的 URL 或本地文件路径 |
|
|
93
|
-
| `-g, --global` | 安装到全局目录 (`~/.claude/skills/` 或 `~/.
|
|
94
|
-
| `-l, --local` | 安装到本地目录 (`./.claude/skills/` 或 `./.
|
|
93
|
+
| `-g, --global` | 安装到全局目录 (`~/.claude/skills/` 或 `~/.agents/skills/`) |
|
|
94
|
+
| `-l, --local` | 安装到本地目录 (`./.claude/skills/` 或 `./.agents/skills/`) |
|
|
95
95
|
| `-u, --username <username>` | Wiki 用户名(用于 Wiki URL) |
|
|
96
96
|
| `-p, --password <password>` | Wiki 密码(或使用环境变量) |
|
|
97
97
|
| `--allow-self-signed` | 允许自签名 SSL 证书(默认:true) |
|
|
@@ -149,8 +149,9 @@ description: 对该技能功能的简要描述
|
|
|
149
149
|
- **本地**:`./.claude/skills/` - 项目特定
|
|
150
150
|
|
|
151
151
|
### Codex
|
|
152
|
-
- **全局**:`~/.
|
|
153
|
-
- **本地**:`./.
|
|
152
|
+
- **全局**:`~/.agents/skills/` - 所有项目可用
|
|
153
|
+
- **本地**:`./.agents/skills/` - 项目特定
|
|
154
|
+
- **兼容**:读取与卸载时会兼容旧目录 `~/.codex/skills/` 和 `./.codex/skills/`
|
|
154
155
|
|
|
155
156
|
### 目录选择
|
|
156
157
|
- **全局**:使用 `--global` 标志或提示时选择 "1"(推荐用于常用技能)
|
|
@@ -177,7 +178,7 @@ skill-install install https://github.com/user/repo/archive/main.zip
|
|
|
177
178
|
# 输出:
|
|
178
179
|
# Select AI tool:
|
|
179
180
|
# 1. Claude Code (~/.claude/skills/)
|
|
180
|
-
# 2. Codex (~/.
|
|
181
|
+
# 2. Codex (~/.agents/skills/)
|
|
181
182
|
#
|
|
182
183
|
# Choose [1/2]: 1
|
|
183
184
|
#
|
|
@@ -251,7 +252,7 @@ skill-install uninstall
|
|
|
251
252
|
# 输出:
|
|
252
253
|
# Select AI tool:
|
|
253
254
|
# 1. Claude Code (~/.claude/skills/)
|
|
254
|
-
# 2. Codex (~/.
|
|
255
|
+
# 2. Codex (~/.agents/skills/)
|
|
255
256
|
#
|
|
256
257
|
# Choose [1/2]: 1
|
|
257
258
|
#
|
|
@@ -5,6 +5,16 @@ export declare class ArchiveExtractor {
|
|
|
5
5
|
* Detect the archive type from a filename
|
|
6
6
|
*/
|
|
7
7
|
detectArchiveType(filename: string): ArchiveType;
|
|
8
|
+
/**
|
|
9
|
+
* Detect archive type from file content first, then fallback to file extension.
|
|
10
|
+
* This supports extensionless archives and custom suffixes like .skill.
|
|
11
|
+
*/
|
|
12
|
+
private resolveArchiveType;
|
|
13
|
+
private detectArchiveTypeWithFileCommand;
|
|
14
|
+
private getFileMimeType;
|
|
15
|
+
private getFileDescription;
|
|
16
|
+
private mapMimeTypeToArchiveType;
|
|
17
|
+
private mapDescriptionToArchiveType;
|
|
8
18
|
/**
|
|
9
19
|
* Extract an archive to a directory
|
|
10
20
|
* @param archivePath Path to the archive file
|
|
@@ -6,10 +6,13 @@ Object.defineProperty(exports, "__esModule", { value: true });
|
|
|
6
6
|
exports.ArchiveExtractor = void 0;
|
|
7
7
|
const promises_1 = require("fs/promises");
|
|
8
8
|
const path_1 = require("path");
|
|
9
|
+
const child_process_1 = require("child_process");
|
|
10
|
+
const util_1 = require("util");
|
|
9
11
|
const tar_1 = require("tar");
|
|
10
12
|
const adm_zip_1 = __importDefault(require("adm-zip"));
|
|
11
13
|
const logger_1 = require("../core/logger");
|
|
12
14
|
const errors_1 = require("../core/errors");
|
|
15
|
+
const execFileAsync = (0, util_1.promisify)(child_process_1.execFile);
|
|
13
16
|
class ArchiveExtractor {
|
|
14
17
|
constructor() {
|
|
15
18
|
this.logger = new logger_1.Logger('ArchiveExtractor');
|
|
@@ -20,10 +23,17 @@ class ArchiveExtractor {
|
|
|
20
23
|
detectArchiveType(filename) {
|
|
21
24
|
const ext = (0, path_1.extname)(filename).toLowerCase();
|
|
22
25
|
const base = (0, path_1.basename)(filename, ext).toLowerCase();
|
|
26
|
+
const lowercaseFilename = filename.toLowerCase();
|
|
23
27
|
// Check for compound extensions
|
|
24
|
-
if (
|
|
28
|
+
if (lowercaseFilename.endsWith('.tar.gz')) {
|
|
25
29
|
return 'tar.gz';
|
|
26
30
|
}
|
|
31
|
+
if (lowercaseFilename.endsWith('.tgz')) {
|
|
32
|
+
return 'tgz';
|
|
33
|
+
}
|
|
34
|
+
if (ext === '.skill') {
|
|
35
|
+
return 'zip';
|
|
36
|
+
}
|
|
27
37
|
if (ext === '.zip') {
|
|
28
38
|
return 'zip';
|
|
29
39
|
}
|
|
@@ -32,6 +42,76 @@ class ArchiveExtractor {
|
|
|
32
42
|
}
|
|
33
43
|
return 'unknown';
|
|
34
44
|
}
|
|
45
|
+
/**
|
|
46
|
+
* Detect archive type from file content first, then fallback to file extension.
|
|
47
|
+
* This supports extensionless archives and custom suffixes like .skill.
|
|
48
|
+
*/
|
|
49
|
+
async resolveArchiveType(archivePath) {
|
|
50
|
+
const byContent = await this.detectArchiveTypeWithFileCommand(archivePath);
|
|
51
|
+
if (byContent !== 'unknown') {
|
|
52
|
+
return byContent;
|
|
53
|
+
}
|
|
54
|
+
return this.detectArchiveType(archivePath);
|
|
55
|
+
}
|
|
56
|
+
async detectArchiveTypeWithFileCommand(archivePath) {
|
|
57
|
+
const mimeType = await this.getFileMimeType(archivePath);
|
|
58
|
+
const byMime = this.mapMimeTypeToArchiveType(mimeType);
|
|
59
|
+
if (byMime !== 'unknown') {
|
|
60
|
+
return byMime;
|
|
61
|
+
}
|
|
62
|
+
const description = await this.getFileDescription(archivePath);
|
|
63
|
+
return this.mapDescriptionToArchiveType(description);
|
|
64
|
+
}
|
|
65
|
+
async getFileMimeType(archivePath) {
|
|
66
|
+
try {
|
|
67
|
+
const result = await execFileAsync('file', ['--brief', '--mime-type', archivePath], { timeout: 5000 });
|
|
68
|
+
return String(result.stdout).trim().toLowerCase();
|
|
69
|
+
}
|
|
70
|
+
catch (error) {
|
|
71
|
+
this.logger.debug(`Unable to detect mime type via file command: ${error}`);
|
|
72
|
+
return '';
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
async getFileDescription(archivePath) {
|
|
76
|
+
try {
|
|
77
|
+
const result = await execFileAsync('file', ['--brief', archivePath], { timeout: 5000 });
|
|
78
|
+
return String(result.stdout).trim().toLowerCase();
|
|
79
|
+
}
|
|
80
|
+
catch (error) {
|
|
81
|
+
this.logger.debug(`Unable to detect description via file command: ${error}`);
|
|
82
|
+
return '';
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
mapMimeTypeToArchiveType(mimeType) {
|
|
86
|
+
if (!mimeType) {
|
|
87
|
+
return 'unknown';
|
|
88
|
+
}
|
|
89
|
+
if (mimeType === 'application/zip') {
|
|
90
|
+
return 'zip';
|
|
91
|
+
}
|
|
92
|
+
if (mimeType === 'application/x-tar') {
|
|
93
|
+
return 'tar';
|
|
94
|
+
}
|
|
95
|
+
if (mimeType === 'application/gzip' || mimeType === 'application/x-gzip') {
|
|
96
|
+
return 'tar.gz';
|
|
97
|
+
}
|
|
98
|
+
return 'unknown';
|
|
99
|
+
}
|
|
100
|
+
mapDescriptionToArchiveType(description) {
|
|
101
|
+
if (!description) {
|
|
102
|
+
return 'unknown';
|
|
103
|
+
}
|
|
104
|
+
if (description.includes('zip archive data')) {
|
|
105
|
+
return 'zip';
|
|
106
|
+
}
|
|
107
|
+
if (description.includes('tar archive')) {
|
|
108
|
+
return 'tar';
|
|
109
|
+
}
|
|
110
|
+
if (description.includes('gzip compressed data')) {
|
|
111
|
+
return 'tar.gz';
|
|
112
|
+
}
|
|
113
|
+
return 'unknown';
|
|
114
|
+
}
|
|
35
115
|
/**
|
|
36
116
|
* Extract an archive to a directory
|
|
37
117
|
* @param archivePath Path to the archive file
|
|
@@ -39,7 +119,7 @@ class ArchiveExtractor {
|
|
|
39
119
|
* @throws WikiError if extraction fails
|
|
40
120
|
*/
|
|
41
121
|
async extract(archivePath, targetDir) {
|
|
42
|
-
const archiveType = this.
|
|
122
|
+
const archiveType = await this.resolveArchiveType(archivePath);
|
|
43
123
|
this.logger.info(`Extracting ${archiveType} archive: ${archivePath} -> ${targetDir}`);
|
|
44
124
|
switch (archiveType) {
|
|
45
125
|
case 'zip':
|
|
@@ -51,7 +131,7 @@ class ArchiveExtractor {
|
|
|
51
131
|
await this.extractTar(archivePath, targetDir);
|
|
52
132
|
break;
|
|
53
133
|
default:
|
|
54
|
-
throw new errors_1.WikiError(errors_1.ErrorType.EXTRACTION_FAILED, `Unknown archive type: ${archivePath}. Supported formats: .zip, .tar, .tar.gz`);
|
|
134
|
+
throw new errors_1.WikiError(errors_1.ErrorType.EXTRACTION_FAILED, `Unknown archive type: ${archivePath}. Supported formats: .zip, .skill, .tar, .tar.gz, .tgz`);
|
|
55
135
|
}
|
|
56
136
|
this.logger.info(`Extraction complete: ${targetDir}`);
|
|
57
137
|
}
|
|
@@ -88,7 +168,7 @@ class ArchiveExtractor {
|
|
|
88
168
|
* Useful for detecting if the archive contains a single root directory
|
|
89
169
|
*/
|
|
90
170
|
async getRootDirectoryName(archivePath) {
|
|
91
|
-
const archiveType = this.
|
|
171
|
+
const archiveType = await this.resolveArchiveType(archivePath);
|
|
92
172
|
if (archiveType === 'zip') {
|
|
93
173
|
return this.getZipRootDir(archivePath);
|
|
94
174
|
}
|
|
@@ -8,6 +8,10 @@ export interface InstalledSkill {
|
|
|
8
8
|
export declare class SkillsManager {
|
|
9
9
|
private readonly logger;
|
|
10
10
|
private readonly validator;
|
|
11
|
+
private getPrimaryToolDirName;
|
|
12
|
+
private getLegacyToolDirNames;
|
|
13
|
+
private getDefaultSkillsDirs;
|
|
14
|
+
private getCandidateSkillsDirs;
|
|
11
15
|
/**
|
|
12
16
|
* Get the global skills directory for the specified AI tool
|
|
13
17
|
*/
|
|
@@ -12,17 +12,37 @@ class SkillsManager {
|
|
|
12
12
|
this.logger = new logger_1.Logger('SkillsManager');
|
|
13
13
|
this.validator = new skill_validator_1.SkillValidator();
|
|
14
14
|
}
|
|
15
|
+
getPrimaryToolDirName(aiTool) {
|
|
16
|
+
return aiTool === 'codex' ? '.agents' : `.${aiTool}`;
|
|
17
|
+
}
|
|
18
|
+
getLegacyToolDirNames(aiTool) {
|
|
19
|
+
return aiTool === 'codex' ? ['.codex'] : [];
|
|
20
|
+
}
|
|
21
|
+
getDefaultSkillsDirs(aiTool = 'claude', isGlobal = true) {
|
|
22
|
+
const basePath = isGlobal ? (0, os_1.homedir)() : process.cwd();
|
|
23
|
+
const dirNames = [
|
|
24
|
+
this.getPrimaryToolDirName(aiTool),
|
|
25
|
+
...this.getLegacyToolDirNames(aiTool)
|
|
26
|
+
];
|
|
27
|
+
return dirNames.map(dirName => (0, path_1.join)(basePath, dirName, 'skills'));
|
|
28
|
+
}
|
|
29
|
+
getCandidateSkillsDirs(skillsDir, aiTool = 'claude') {
|
|
30
|
+
if (skillsDir) {
|
|
31
|
+
return [skillsDir];
|
|
32
|
+
}
|
|
33
|
+
return this.getDefaultSkillsDirs(aiTool, true);
|
|
34
|
+
}
|
|
15
35
|
/**
|
|
16
36
|
* Get the global skills directory for the specified AI tool
|
|
17
37
|
*/
|
|
18
38
|
getGlobalSkillsDir(aiTool = 'claude') {
|
|
19
|
-
return (
|
|
39
|
+
return this.getDefaultSkillsDirs(aiTool, true)[0];
|
|
20
40
|
}
|
|
21
41
|
/**
|
|
22
42
|
* Get the local skills directory for the specified AI tool
|
|
23
43
|
*/
|
|
24
44
|
getLocalSkillsDir(aiTool = 'claude') {
|
|
25
|
-
return
|
|
45
|
+
return this.getDefaultSkillsDirs(aiTool, false)[0];
|
|
26
46
|
}
|
|
27
47
|
/**
|
|
28
48
|
* Get both global and local directories for an AI tool
|
|
@@ -51,40 +71,45 @@ class SkillsManager {
|
|
|
51
71
|
* @returns Array of installed skill metadata
|
|
52
72
|
*/
|
|
53
73
|
async listInstalled(skillsDir, aiTool = 'claude') {
|
|
54
|
-
const
|
|
55
|
-
const skills =
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
74
|
+
const candidateDirs = this.getCandidateSkillsDirs(skillsDir, aiTool);
|
|
75
|
+
const skills = new Map();
|
|
76
|
+
for (const dir of candidateDirs) {
|
|
77
|
+
this.logger.info(`Listing skills in: ${dir}`);
|
|
78
|
+
try {
|
|
79
|
+
const entries = await (0, promises_1.readdir)(dir, { withFileTypes: true });
|
|
80
|
+
for (const entry of entries) {
|
|
81
|
+
if (!entry.isDirectory()) {
|
|
82
|
+
continue;
|
|
83
|
+
}
|
|
84
|
+
const skillPath = (0, path_1.join)(dir, entry.name);
|
|
85
|
+
try {
|
|
86
|
+
const metadata = await this.validator.validateOrThrow(skillPath);
|
|
87
|
+
// Keep the first discovered skill path (primary directory wins).
|
|
88
|
+
if (!skills.has(metadata.name)) {
|
|
89
|
+
skills.set(metadata.name, {
|
|
90
|
+
name: metadata.name,
|
|
91
|
+
description: metadata.description,
|
|
92
|
+
path: skillPath,
|
|
93
|
+
tool: aiTool
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
catch {
|
|
98
|
+
// Skip invalid entries but log a warning
|
|
99
|
+
this.logger.warn(`Skipping invalid skill directory: ${entry.name}`);
|
|
100
|
+
}
|
|
76
101
|
}
|
|
77
102
|
}
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
103
|
+
catch (error) {
|
|
104
|
+
if (error.code === 'ENOENT') {
|
|
105
|
+
// Directory doesn't exist yet, continue to next candidate
|
|
106
|
+
this.logger.info(`Skills directory does not exist yet: ${dir}`);
|
|
107
|
+
continue;
|
|
108
|
+
}
|
|
109
|
+
throw error;
|
|
85
110
|
}
|
|
86
|
-
throw error;
|
|
87
111
|
}
|
|
112
|
+
return Array.from(skills.values());
|
|
88
113
|
}
|
|
89
114
|
/**
|
|
90
115
|
* Uninstall a skill by name
|
|
@@ -94,14 +119,13 @@ class SkillsManager {
|
|
|
94
119
|
* @throws WikiError if skill not found or deletion fails
|
|
95
120
|
*/
|
|
96
121
|
async uninstall(name, skillsDir, aiTool = 'claude') {
|
|
97
|
-
const
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
const skills = await this.listInstalled(dir, aiTool);
|
|
122
|
+
const candidateDirs = this.getCandidateSkillsDirs(skillsDir, aiTool);
|
|
123
|
+
this.logger.info(`Uninstalling skill: ${name} from ${candidateDirs.join(', ')}`);
|
|
124
|
+
// Check if skill exists across candidate directories.
|
|
125
|
+
const skills = await this.listInstalled(skillsDir, aiTool);
|
|
102
126
|
const skill = skills.find(s => s.name === name);
|
|
103
127
|
if (!skill) {
|
|
104
|
-
throw new errors_1.WikiError(errors_1.ErrorType.NOT_FOUND, `Skill "${name}" not found in ${
|
|
128
|
+
throw new errors_1.WikiError(errors_1.ErrorType.NOT_FOUND, `Skill "${name}" not found in ${candidateDirs.join(', ')}`);
|
|
105
129
|
}
|
|
106
130
|
// Delete the skill directory
|
|
107
131
|
try {
|
package/dist/ui/prompts.js
CHANGED
|
@@ -106,7 +106,7 @@ async function promptAiTool() {
|
|
|
106
106
|
message: 'Select AI tool',
|
|
107
107
|
choices: [
|
|
108
108
|
{ value: 'claude', name: 'Claude Code', description: '~/.claude/skills/' },
|
|
109
|
-
{ value: 'codex', name: 'Codex', description: '~/.
|
|
109
|
+
{ value: 'codex', name: 'Codex', description: '~/.agents/skills/' },
|
|
110
110
|
],
|
|
111
111
|
theme: commonTheme,
|
|
112
112
|
});
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@sstar/skill-install",
|
|
3
|
-
"version": "1.2.
|
|
3
|
+
"version": "1.2.3",
|
|
4
4
|
"description": "Agent Skill installation tool - download, extract, validate, and install skills for Claude Code and Codex",
|
|
5
5
|
"main": "dist/index.js",
|
|
6
6
|
"bin": {
|
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
},
|
|
9
9
|
"scripts": {
|
|
10
10
|
"build": "tsc",
|
|
11
|
+
"test": "npm run build --silent && node --test tests/**/*.test.js",
|
|
11
12
|
"prepublishOnly": "npm run build",
|
|
12
13
|
"start": "node dist/cli.js",
|
|
13
14
|
"dev": "ts-node src/cli.ts",
|