@botskill/cli 1.0.5 → 1.0.7

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@botskill/cli",
3
- "version": "1.0.5",
3
+ "version": "1.0.7",
4
4
  "description": "CLI tool for BotSkill - AI agent skills platform",
5
5
  "main": "src/index.js",
6
6
  "bin": {
@@ -0,0 +1,57 @@
1
+ import { Command } from 'commander';
2
+ import path from 'path';
3
+ import fs from 'fs-extra';
4
+ import { packDirectory } from '../lib/packSkill.js';
5
+ import { printSimpleError } from '../lib/formatError.js';
6
+
7
+ const packCommand = new Command('pack');
8
+ packCommand
9
+ .description('Pack current or specified directory into upload format (skill.zip)')
10
+ .argument('[path]', 'Directory to pack (default: current directory)')
11
+ .option('-o, --output <file>', 'Output file path (default: skill.zip in source directory)')
12
+ .option('--dry-run', 'Validate only, do not create zip')
13
+ .action(async (pathArg, options) => {
14
+ const cwd = process.cwd();
15
+ const dirPath = pathArg ? path.resolve(cwd, pathArg) : cwd;
16
+
17
+ if (!(await fs.pathExists(dirPath))) {
18
+ printSimpleError('Directory not found', dirPath);
19
+ }
20
+
21
+ const stat = await fs.stat(dirPath);
22
+ if (!stat.isDirectory()) {
23
+ printSimpleError('Not a directory', dirPath);
24
+ }
25
+
26
+ const skillMdPath = path.join(dirPath, 'SKILL.md');
27
+ if (!(await fs.pathExists(skillMdPath))) {
28
+ printSimpleError('SKILL.md not found', 'Create SKILL.md in the directory first');
29
+ }
30
+
31
+ if (options.dryRun) {
32
+ const { getConfigFromDir } = await import('../lib/packSkill.js');
33
+ const { name, version } = await getConfigFromDir(dirPath).catch(() => ({ name: 'skill', version: '1.0.0' }));
34
+ const toSafe = (s) => String(s || 'skill').toLowerCase().replace(/[^a-z0-9.-]/g, '-').replace(/-+/g, '-').replace(/^-|-$/g, '') || 'skill';
35
+ const defaultOutput = path.join(dirPath, `${toSafe(name)}-${String(version || '1.0.0').replace(/[^a-zA-Z0-9.-]/g, '-')}.zip`);
36
+ console.log('[DRY RUN] Would pack:', dirPath);
37
+ console.log('[DRY RUN] Output:', options.output || defaultOutput);
38
+ return;
39
+ }
40
+
41
+ try {
42
+ const outputPath = await packDirectory(dirPath, { output: options.output });
43
+ console.log('Packed successfully!');
44
+ console.log(`Output: ${outputPath}`);
45
+ console.log('\nUse "skm push" or "skm push -f <path>" to upload.');
46
+ } catch (err) {
47
+ if (err.message === 'NO_SKILL_MD') {
48
+ printSimpleError('SKILL.md not found', 'Create SKILL.md in the directory first');
49
+ } else if (err.message === 'FILE_NOT_FOUND') {
50
+ printSimpleError('Directory not found', dirPath);
51
+ } else {
52
+ printSimpleError(err.message || 'Pack failed');
53
+ }
54
+ }
55
+ });
56
+
57
+ export { packCommand };
@@ -4,11 +4,13 @@ import fs from 'fs-extra';
4
4
  import { getToken } from '../lib/auth.js';
5
5
  import { printApiError, printSimpleError } from '../lib/formatError.js';
6
6
  import { uploadSkillFile, findUploadFile } from '../lib/uploadSkill.js';
7
+ import { packDirectory } from '../lib/packSkill.js';
7
8
 
8
9
  const publishCommand = new Command('publish');
9
10
  publishCommand
10
11
  .description('Publish a skill to BotSkill (alias for push)')
11
12
  .option('-f, --file <path>', 'Path to SKILL.md, .zip, or .tar.gz')
13
+ .option('--config-dir <path>', 'Directory containing skill.config.json (default: same as file)')
12
14
  .option('--dry-run', 'Validate without uploading')
13
15
  .option('--api-url <url>', 'API base URL (overrides config for this command)')
14
16
  .action(async (options, command) => {
@@ -32,14 +34,37 @@ publishCommand
32
34
  }
33
35
  }
34
36
 
35
- if (options.dryRun) {
37
+ const configDir = options.configDir ? path.resolve(options.configDir) : path.dirname(path.resolve(filePath));
38
+ let tempPackPath = null;
39
+ let uploadPath = filePath;
40
+
41
+ const isSkillMd = filePath.toLowerCase().endsWith('.md') || path.basename(filePath) === 'SKILL.md';
42
+ if (isSkillMd) {
43
+ const dirPath = path.dirname(path.resolve(filePath));
44
+ if (options.dryRun) {
45
+ console.log('[DRY RUN] Would pack directory to temp (skill-{version}.zip) and upload');
46
+ console.log('[DRY RUN] Source:', path.resolve(filePath));
47
+ return;
48
+ }
49
+ try {
50
+ tempPackPath = await packDirectory(dirPath, { useTempDir: true });
51
+ uploadPath = tempPackPath;
52
+ } catch (err) {
53
+ if (err.message === 'NO_SKILL_MD') {
54
+ printSimpleError('SKILL.md not found', 'Create SKILL.md in the directory first');
55
+ } else {
56
+ printSimpleError(err.message || 'Pack failed');
57
+ }
58
+ process.exit(1);
59
+ }
60
+ } else if (options.dryRun) {
36
61
  console.log('[DRY RUN] Would publish:', path.resolve(filePath));
37
62
  return;
38
63
  }
39
64
 
40
- console.log(`Publishing skill from ${path.basename(filePath)}...`);
65
+ console.log(`Publishing skill from ${path.basename(uploadPath)}...`);
41
66
  try {
42
- const skill = await uploadSkillFile(filePath, { apiUrl });
67
+ const skill = await uploadSkillFile(uploadPath, { apiUrl, configDir });
43
68
  console.log('Skill published successfully!');
44
69
  console.log(`Name: ${skill?.name}`);
45
70
  console.log(`Version: ${skill?.version || (skill?.versions?.[0]?.version)}`);
@@ -48,13 +73,19 @@ publishCommand
48
73
  if (err.message === 'NOT_LOGGED_IN') {
49
74
  printSimpleError('Not logged in', 'Run "skm login" first');
50
75
  } else if (err.message === 'FILE_NOT_FOUND') {
51
- printSimpleError('File not found', 'Check the path with --file <path>');
76
+ printSimpleError('File not found', uploadPath);
52
77
  } else {
53
78
  const msg = err.response?.data?.error || err.response?.data?.details?.[0];
54
79
  if (msg) err._overrideMsg = msg;
55
80
  if (err.response?.status === 401) err._overrideMsg = 'Token expired or invalid. Run "skm login" first.';
56
81
  printApiError(err, { prefix: 'Publish failed' });
57
82
  }
83
+ } finally {
84
+ if (tempPackPath) {
85
+ try {
86
+ await fs.remove(path.dirname(tempPackPath));
87
+ } catch (_) {}
88
+ }
58
89
  }
59
90
  });
60
91
 
@@ -3,12 +3,14 @@ import path from 'path';
3
3
  import fs from 'fs-extra';
4
4
  import { getToken } from '../lib/auth.js';
5
5
  import { printApiError, printSimpleError } from '../lib/formatError.js';
6
- import { uploadSkillFile, findUploadFile, validCategories } from '../lib/uploadSkill.js';
6
+ import { uploadSkillFile, findUploadFile } from '../lib/uploadSkill.js';
7
+ import { packDirectory } from '../lib/packSkill.js';
7
8
 
8
9
  const pushCommand = new Command('push');
9
10
  pushCommand
10
11
  .description('Upload/push a skill to BotSkill (SKILL.md, .zip, or .tar.gz)')
11
12
  .option('-f, --file <path>', 'Path to SKILL.md, .zip, or .tar.gz')
13
+ .option('--config-dir <path>', 'Directory containing skill.config.json (default: same as file)')
12
14
  .option('--dry-run', 'Validate without uploading')
13
15
  .option('--api-url <url>', 'API base URL')
14
16
  .action(async (options, command) => {
@@ -32,14 +34,38 @@ pushCommand
32
34
  }
33
35
  }
34
36
 
35
- if (options.dryRun) {
37
+ const configDir = options.configDir ? path.resolve(options.configDir) : path.dirname(path.resolve(filePath));
38
+ let tempPackPath = null;
39
+ let uploadPath = filePath;
40
+
41
+ // 当源是 SKILL.md 时,打包到临时目录并加上版本号
42
+ const isSkillMd = filePath.toLowerCase().endsWith('.md') || path.basename(filePath) === 'SKILL.md';
43
+ if (isSkillMd) {
44
+ const dirPath = path.dirname(path.resolve(filePath));
45
+ if (options.dryRun) {
46
+ console.log('[DRY RUN] Would pack directory to temp (skill-{version}.zip) and upload');
47
+ console.log('[DRY RUN] Source:', path.resolve(filePath));
48
+ return;
49
+ }
50
+ try {
51
+ tempPackPath = await packDirectory(dirPath, { useTempDir: true });
52
+ uploadPath = tempPackPath;
53
+ } catch (err) {
54
+ if (err.message === 'NO_SKILL_MD') {
55
+ printSimpleError('SKILL.md not found', 'Create SKILL.md in the directory first');
56
+ } else {
57
+ printSimpleError(err.message || 'Pack failed');
58
+ }
59
+ process.exit(1);
60
+ }
61
+ } else if (options.dryRun) {
36
62
  console.log('[DRY RUN] Would upload:', path.resolve(filePath));
37
63
  return;
38
64
  }
39
65
 
40
- console.log(`Pushing skill from ${path.basename(filePath)}...`);
66
+ console.log(`Pushing skill from ${path.basename(uploadPath)}...`);
41
67
  try {
42
- const skill = await uploadSkillFile(filePath, { apiUrl });
68
+ const skill = await uploadSkillFile(uploadPath, { apiUrl, configDir });
43
69
  console.log('Skill uploaded successfully!');
44
70
  console.log(`Name: ${skill?.name}`);
45
71
  console.log(`Version: ${skill?.version || (skill?.versions?.[0]?.version)}`);
@@ -48,13 +74,19 @@ pushCommand
48
74
  if (err.message === 'NOT_LOGGED_IN') {
49
75
  printSimpleError('Not logged in', 'Run "skm login" first');
50
76
  } else if (err.message === 'FILE_NOT_FOUND') {
51
- printSimpleError('File not found', 'Check the path with --file <path>');
77
+ printSimpleError('File not found', uploadPath);
52
78
  } else {
53
79
  const msg = err.response?.data?.error || err.response?.data?.details?.[0];
54
80
  if (msg) err._overrideMsg = msg;
55
81
  if (err.response?.status === 401) err._overrideMsg = 'Token expired or invalid. Run "skm login" first.';
56
82
  printApiError(err, { prefix: 'Push failed' });
57
83
  }
84
+ } finally {
85
+ if (tempPackPath) {
86
+ try {
87
+ await fs.remove(path.dirname(tempPackPath));
88
+ } catch (_) {}
89
+ }
58
90
  }
59
91
  });
60
92
 
package/src/index.js CHANGED
@@ -9,6 +9,7 @@ import { loginCommand } from './commands/login.js';
9
9
  import { logoutCommand } from './commands/logout.js';
10
10
  import { configCommand } from './commands/config.js';
11
11
  import { getCommand } from './commands/get.js';
12
+ import { packCommand } from './commands/pack.js';
12
13
  import { pushCommand } from './commands/push.js';
13
14
  import { publishCommand } from './commands/publish.js';
14
15
  import { listCommand } from './commands/list.js';
@@ -36,6 +37,7 @@ program.addCommand(loginCommand);
36
37
  program.addCommand(logoutCommand);
37
38
  program.addCommand(configCommand);
38
39
  program.addCommand(getCommand);
40
+ program.addCommand(packCommand);
39
41
  program.addCommand(pushCommand);
40
42
  program.addCommand(publishCommand);
41
43
  program.addCommand(listCommand);
@@ -0,0 +1,97 @@
1
+ import path from 'path';
2
+ import os from 'os';
3
+ import fs from 'fs-extra';
4
+ import AdmZip from 'adm-zip';
5
+
6
+ const DEFAULT_EXCLUDE = [
7
+ 'node_modules',
8
+ '.git',
9
+ '.DS_Store',
10
+ 'skill.zip',
11
+ 'skill.tar.gz',
12
+ 'dist.zip',
13
+ ];
14
+
15
+ /**
16
+ * Create filter that excludes common unwanted paths
17
+ */
18
+ function createExcludeFilter(exclude = DEFAULT_EXCLUDE) {
19
+ const patterns = exclude.map((p) => p.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'));
20
+ const re = new RegExp(`(^|/)(${patterns.join('|')})(/|$)`, 'i');
21
+ return (zipPath) => !re.test(zipPath.replace(/\\/g, '/'));
22
+ }
23
+
24
+ /**
25
+ * Get name and version from skill.config.json in directory
26
+ */
27
+ export async function getConfigFromDir(dirPath) {
28
+ const configPath = path.join(dirPath, 'skill.config.json');
29
+ const defaultValue = { name: 'skill', version: '1.0.0' };
30
+ if (!(await fs.pathExists(configPath))) return defaultValue;
31
+ try {
32
+ const config = await fs.readJson(configPath);
33
+ return {
34
+ name: config.name || 'skill',
35
+ version: config.version || '1.0.0',
36
+ };
37
+ } catch {
38
+ return defaultValue;
39
+ }
40
+ }
41
+
42
+ function toSafeId(str) {
43
+ return String(str || 'skill')
44
+ .toLowerCase()
45
+ .replace(/[^a-z0-9.-]/g, '-')
46
+ .replace(/-+/g, '-')
47
+ .replace(/^-|-$/g, '') || 'skill';
48
+ }
49
+
50
+ /**
51
+ * Pack a directory into skill.zip format for upload
52
+ * @param {string} dirPath - Directory to pack (must contain SKILL.md)
53
+ * @param {Object} [opts]
54
+ * @param {string} [opts.output] - Output file path (default: skill.zip in dirPath)
55
+ * @param {string} [opts.version] - Version for filename when using tempDir
56
+ * @param {boolean} [opts.useTempDir] - If true, output to temp dir with skill-{version}.zip
57
+ * @param {string[]} [opts.exclude] - Additional paths to exclude
58
+ * @returns {Promise<string>} Path to created zip file
59
+ */
60
+ export async function packDirectory(dirPath, opts = {}) {
61
+ const resolved = path.resolve(dirPath);
62
+ if (!(await fs.pathExists(resolved))) {
63
+ throw new Error('FILE_NOT_FOUND');
64
+ }
65
+ const stat = await fs.stat(resolved);
66
+ if (!stat.isDirectory()) {
67
+ throw new Error('NOT_DIRECTORY');
68
+ }
69
+
70
+ const skillMdPath = path.join(resolved, 'SKILL.md');
71
+ if (!(await fs.pathExists(skillMdPath))) {
72
+ throw new Error('NO_SKILL_MD');
73
+ }
74
+
75
+ const { name, version } = await getConfigFromDir(resolved);
76
+ const safeName = toSafeId(opts.name ?? name);
77
+ const safeVersion = String(opts.version ?? version).replace(/[^a-zA-Z0-9.-]/g, '-');
78
+ const defaultFilename = `${safeName}-${safeVersion}.zip`;
79
+
80
+ let outputPath;
81
+ if (opts.output) {
82
+ outputPath = path.resolve(opts.output);
83
+ } else if (opts.useTempDir) {
84
+ const tmpDir = path.join(os.tmpdir(), `skm-pack-${Date.now()}-${Math.random().toString(36).slice(2)}`);
85
+ await fs.ensureDir(tmpDir);
86
+ outputPath = path.join(tmpDir, defaultFilename);
87
+ } else {
88
+ outputPath = path.join(resolved, defaultFilename);
89
+ }
90
+ const excludeFilter = createExcludeFilter(opts.exclude);
91
+
92
+ const zip = new AdmZip();
93
+ zip.addLocalFolder(resolved, '', (p) => excludeFilter(p.replace(/\\/g, '/')));
94
+ zip.writeZip(outputPath);
95
+
96
+ return outputPath;
97
+ }
@@ -6,7 +6,7 @@ const validCategories = ['ai', 'data', 'web', 'devops', 'security', 'tools'];
6
6
 
7
7
  /**
8
8
  * Find uploadable file in cwd
9
- * Priority: SKILL.md, skill.zip, skill.tar.gz, dist.zip
9
+ * Priority: SKILL.md, skill.zip, skill.tar.gz, dist.zip, {name}-{version}.zip
10
10
  */
11
11
  export async function findUploadFile(cwd = process.cwd()) {
12
12
  const candidates = [
@@ -21,14 +21,63 @@ export async function findUploadFile(cwd = process.cwd()) {
21
21
  if (stat.isFile()) return p;
22
22
  }
23
23
  }
24
+ // 查找 {name}-{version}.zip 格式的打包文件
25
+ try {
26
+ const files = await fs.readdir(cwd);
27
+ const zipMatch = files.find((f) => /-\d+\.\d+\.\d+\.zip$/i.test(f));
28
+ if (zipMatch) {
29
+ const p = path.join(cwd, zipMatch);
30
+ const stat = await fs.stat(p);
31
+ if (stat.isFile()) return p;
32
+ }
33
+ } catch (_) {}
24
34
  return null;
25
35
  }
26
36
 
37
+ /**
38
+ * Load skill.config.json from directory (same dir as file or parent)
39
+ */
40
+ export async function loadSkillConfig(filePath) {
41
+ const dir = path.dirname(path.resolve(filePath));
42
+ const configPath = path.join(dir, 'skill.config.json');
43
+ if (!(await fs.pathExists(configPath))) return null;
44
+ try {
45
+ return await fs.readJson(configPath);
46
+ } catch {
47
+ return null;
48
+ }
49
+ }
50
+
51
+ /**
52
+ * Append skill.config.json fields to FormData (version, category, license, tags, urls)
53
+ */
54
+ function appendConfigToForm(form, config) {
55
+ if (!config) return;
56
+ const fields = [
57
+ 'version',
58
+ 'category',
59
+ 'license',
60
+ 'repositoryUrl',
61
+ 'documentationUrl',
62
+ 'demoUrl',
63
+ ];
64
+ for (const key of fields) {
65
+ const val = config[key];
66
+ if (val !== undefined && val !== null && val !== '') {
67
+ form.append(key, Array.isArray(val) ? JSON.stringify(val) : String(val));
68
+ }
69
+ }
70
+ if (Array.isArray(config.tags) && config.tags.length > 0) {
71
+ form.append('tags', JSON.stringify(config.tags));
72
+ }
73
+ }
74
+
27
75
  /**
28
76
  * Upload skill file (SKILL.md, .zip, .tar.gz) to BotSkill
29
77
  * @param {string} filePath - Path to file
30
78
  * @param {Object} [opts] - 可选
31
79
  * @param {string} [opts.apiUrl] - 覆盖 API 地址(来自 --api-url)
80
+ * @param {string} [opts.configDir] - 指定读取 skill.config.json 的目录(默认与文件同目录)
32
81
  */
33
82
  export async function uploadSkillFile(filePath, opts = {}) {
34
83
  const token = getToken();
@@ -46,6 +95,15 @@ export async function uploadSkillFile(filePath, opts = {}) {
46
95
  filename: path.basename(filePath),
47
96
  });
48
97
 
98
+ const configDir = opts.configDir ?? path.dirname(path.resolve(filePath));
99
+ const configPath = path.join(configDir, 'skill.config.json');
100
+ if (await fs.pathExists(configPath)) {
101
+ try {
102
+ const config = await fs.readJson(configPath);
103
+ appendConfigToForm(form, config);
104
+ } catch (_) {}
105
+ }
106
+
49
107
  const api = createApiClient(opts.apiUrl);
50
108
  const res = await api.post('/skills/upload', form, {
51
109
  headers: form.getHeaders(),