@sstar/skill-install 1.2.1 → 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 (`~/.codex/skills/`)
7
+ - **多工具支持**:同时支持 Claude Code (`~/.claude/skills/`) 和 Codex (`~/.agents/skills/`)
8
8
  - **自动检测来源**:自动识别来源类型(公开 URL、需认证的 Wiki URL 或本地文件)
9
- - **多种归档格式**:支持 `.zip` 和 `.tar.gz` 归档文件
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/` 或 `~/.codex/skills/`) |
94
- | `-l, --local` | 安装到本地目录 (`./.claude/skills/` 或 `./.codex/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
- - **全局**:`~/.codex/skills/` - 所有项目可用
153
- - **本地**:`./.codex/skills/` - 项目特定
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 (~/.codex/skills/)
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 (~/.codex/skills/)
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 (filename.toLowerCase().endsWith('.tar.gz') || filename.toLowerCase().endsWith('.tgz')) {
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.detectArchiveType(archivePath);
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.detectArchiveType(archivePath);
171
+ const archiveType = await this.resolveArchiveType(archivePath);
92
172
  if (archiveType === 'zip') {
93
173
  return this.getZipRootDir(archivePath);
94
174
  }
package/dist/cli.js CHANGED
@@ -7,6 +7,8 @@ Object.defineProperty(exports, "__esModule", { value: true });
7
7
  const commander_1 = require("commander");
8
8
  const os_1 = __importDefault(require("os"));
9
9
  const picocolors_1 = __importDefault(require("picocolors"));
10
+ const fs_1 = require("fs");
11
+ const path_1 = require("path");
10
12
  const install_service_1 = require("./installer/install-service");
11
13
  const skills_manager_1 = require("./skills/skills-manager");
12
14
  const plugin_manager_1 = require("./plugins/plugin-manager");
@@ -14,12 +16,15 @@ const logger_1 = require("./core/logger");
14
16
  const wiki_1 = require("./wiki");
15
17
  const types_1 = require("./plugins/types");
16
18
  const ui_1 = require("./ui");
19
+ // Read version from package.json
20
+ const pkg = JSON.parse((0, fs_1.readFileSync)((0, path_1.join)(__dirname, '..', 'package.json'), 'utf-8'));
21
+ const VERSION = pkg.version;
17
22
  const program = new commander_1.Command();
18
23
  const skillsManager = new skills_manager_1.SkillsManager();
19
24
  program
20
25
  .name('skill-install')
21
26
  .description('Agent Skill installation tool - download, extract, validate, and install skills')
22
- .version('1.0.0')
27
+ .version(VERSION)
23
28
  .option('-t, --tool <tool>', 'AI tool: claude or codex (skip prompt if specified)')
24
29
  .option('-s, --skills-dir <path>', 'Custom skills directory')
25
30
  .option('-v, --verbose', 'Enable verbose logging');
@@ -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 (0, path_1.join)((0, os_1.homedir)(), `.${aiTool}`, 'skills');
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 (0, path_1.join)(process.cwd(), `.${aiTool}`, 'skills');
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 dir = this.getEffectiveSkillsDir(skillsDir, aiTool);
55
- const skills = [];
56
- this.logger.info(`Listing skills in: ${dir}`);
57
- try {
58
- const entries = await (0, promises_1.readdir)(dir, { withFileTypes: true });
59
- for (const entry of entries) {
60
- if (!entry.isDirectory()) {
61
- continue;
62
- }
63
- const skillPath = (0, path_1.join)(dir, entry.name);
64
- try {
65
- const metadata = await this.validator.validateOrThrow(skillPath);
66
- skills.push({
67
- name: metadata.name,
68
- description: metadata.description,
69
- path: skillPath,
70
- tool: aiTool
71
- });
72
- }
73
- catch (error) {
74
- // Skip invalid entries but log a warning
75
- this.logger.warn(`Skipping invalid skill directory: ${entry.name}`);
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
- return skills;
79
- }
80
- catch (error) {
81
- if (error.code === 'ENOENT') {
82
- // Directory doesn't exist yet, return empty array
83
- this.logger.info('Skills directory does not exist yet');
84
- return [];
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 dir = this.getEffectiveSkillsDir(skillsDir, aiTool);
98
- const skillPath = (0, path_1.join)(dir, name);
99
- this.logger.info(`Uninstalling skill: ${name} from ${skillPath}`);
100
- // Check if skill exists
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 ${dir}`);
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 {
@@ -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: '~/.codex/skills/' },
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.1",
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",