@botskill/cli 1.0.4 → 1.0.6

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.4",
3
+ "version": "1.0.6",
4
4
  "description": "CLI tool for BotSkill - AI agent skills platform",
5
5
  "main": "src/index.js",
6
6
  "bin": {
@@ -25,7 +25,8 @@ getCommand
25
25
  .option('-o, --output <dir>', 'Output directory (default: current directory)')
26
26
  .option('--dry-run', 'Show what would be downloaded without actually downloading')
27
27
  .option('--api-url <url>', 'API base URL (overrides config for this command)')
28
- .action(async (specifier, options) => {
28
+ .action(async (specifier, options, command) => {
29
+ const apiUrl = command.optsWithGlobals().apiUrl;
29
30
  const { name, version } = parseSpecifier(specifier);
30
31
  const outputDir = path.resolve(options.output || process.cwd());
31
32
 
@@ -42,7 +43,7 @@ getCommand
42
43
  }
43
44
 
44
45
  try {
45
- const api = createApiClient(options.apiUrl);
46
+ const api = createApiClient(apiUrl);
46
47
 
47
48
  const fullSpec = version ? `${name}@${version}` : name;
48
49
  console.log(`Downloading skill: ${fullSpec}`);
@@ -17,7 +17,7 @@ infoCommand
17
17
  .description('Show skill details from BotSkill')
18
18
  .argument('<specifier>', 'Skill name or name@version')
19
19
  .option('--api-url <url>', 'API base URL (overrides config for this command)')
20
- .action(async (specifier, options = {}) => {
20
+ .action(async (specifier, options, command) => {
21
21
  const { name, version } = parseSpecifier(specifier);
22
22
 
23
23
  if (!name) {
@@ -25,8 +25,9 @@ infoCommand
25
25
  process.exit(1);
26
26
  }
27
27
 
28
+ const apiUrl = command.optsWithGlobals().apiUrl;
28
29
  try {
29
- const api = createApiClient(options.apiUrl);
30
+ const api = createApiClient(apiUrl);
30
31
  const fullSpec = version ? `${name}@${version}` : name;
31
32
 
32
33
  const resolveRes = await api.get(`/skills/by-name/${encodeURIComponent(fullSpec)}`);
@@ -3,22 +3,25 @@ import path from 'path';
3
3
  import fs from 'fs-extra';
4
4
  import inquirer from 'inquirer';
5
5
 
6
+ function toSkillName(raw) {
7
+ const s = String(raw || 'my-skill').trim();
8
+ return s.toLowerCase()
9
+ .replace(/\s+/g, '-')
10
+ .replace(/[^a-z0-9-]/g, '')
11
+ .replace(/-+/g, '-')
12
+ .replace(/^-|-$/g, '') || 'my-skill';
13
+ }
14
+
6
15
  const initCommand = new Command('init');
7
16
  initCommand
8
17
  .description('Initialize a new skill project')
18
+ .argument('[path]', 'Target directory: "." or path for current/specified dir; omit to create skill-named directory')
9
19
  .option('-n, --name <name>', 'Project/skill name')
10
20
  .option('-d, --description <description>', 'Skill description')
11
21
  .option('-c, --category <category>', 'Category: ai, data, web, devops, security, tools')
12
22
  .option('-y, --yes', 'Use defaults without prompting')
13
- .action(async (options) => {
23
+ .action(async (pathArg, options) => {
14
24
  const cwd = process.cwd();
15
- const configPath = path.join(cwd, 'skill.config.json');
16
-
17
- if (await fs.pathExists(configPath)) {
18
- console.error('skill.config.json already exists in this directory.');
19
- process.exit(1);
20
- }
21
-
22
25
  const validCategories = ['ai', 'data', 'web', 'devops', 'security', 'tools'];
23
26
  let answers = {};
24
27
 
@@ -68,6 +71,25 @@ initCommand
68
71
  ]);
69
72
  }
70
73
 
74
+ const skillName = toSkillName(answers.name);
75
+ let targetDir;
76
+
77
+ if (pathArg && pathArg !== '.') {
78
+ targetDir = path.resolve(cwd, pathArg);
79
+ await fs.ensureDir(targetDir);
80
+ } else if (pathArg === '.') {
81
+ targetDir = cwd;
82
+ } else {
83
+ targetDir = path.join(cwd, skillName);
84
+ await fs.ensureDir(targetDir);
85
+ }
86
+
87
+ const configPath = path.join(targetDir, 'skill.config.json');
88
+ if (await fs.pathExists(configPath)) {
89
+ console.error(`skill.config.json already exists in ${targetDir}`);
90
+ process.exit(1);
91
+ }
92
+
71
93
  const config = {
72
94
  name: answers.name,
73
95
  description: answers.description,
@@ -82,12 +104,6 @@ initCommand
82
104
 
83
105
  await fs.writeJson(configPath, config, { spaces: 2 });
84
106
 
85
- // Agent Skills spec: https://agentskills.io/specification
86
- // name: required, 1-64 chars, lowercase + hyphens
87
- // description: required, max 1024 chars
88
- // metadata.version, metadata.author: optional
89
- const rawName = String(answers.name || 'my-skill').trim();
90
- const skillName = rawName.toLowerCase().replace(/\s+/g, '-').replace(/[^a-z0-9-]/g, '').replace(/-+/g, '-').replace(/^-|-$/g, '') || 'my-skill';
91
107
  const skillMd = `---
92
108
  name: ${skillName}
93
109
  description: ${answers.description}
@@ -115,10 +131,13 @@ Add your usage documentation here. The Markdown body contains skill instructions
115
131
  \`\`\`
116
132
  `;
117
133
 
118
- const skillMdPath = path.join(cwd, 'SKILL.md');
134
+ const skillMdPath = path.join(targetDir, 'SKILL.md');
119
135
  await fs.writeFile(skillMdPath, skillMd, 'utf-8');
120
136
 
121
137
  console.log('Created skill.config.json and SKILL.md');
138
+ if (targetDir !== cwd) {
139
+ console.log(`Location: ${targetDir}`);
140
+ }
122
141
  console.log('\nNext steps:');
123
142
  console.log('1. Edit SKILL.md to add documentation (the content below the frontmatter)');
124
143
  console.log('2. Run "skm login" to authenticate');
@@ -8,7 +8,9 @@ function formatSkillDisplay(skill) {
8
8
  const downloads = skill.downloads ?? 0;
9
9
  const category = skill.category || '—';
10
10
  const status = skill.status || '—';
11
- return { name, version, downloads, category, status };
11
+ const desc = (skill.description || '').trim();
12
+ const description = desc.length > 80 ? desc.slice(0, 77) + '...' : desc || '—';
13
+ return { name, version, downloads, category, status, description };
12
14
  }
13
15
 
14
16
  const listCommand = new Command('list');
@@ -21,8 +23,9 @@ listCommand
21
23
  .option('-l, --limit <number>', 'Maximum number of results (default: 20)', '20')
22
24
  .option('-p, --page <number>', 'Page number for pagination (default: 1)', '1')
23
25
  .option('--api-url <url>', 'API base URL (overrides config for this command)')
24
- .action(async (options) => {
25
- const api = createApiClient(options.apiUrl);
26
+ .action(async (options, command) => {
27
+ const apiUrl = command.optsWithGlobals().apiUrl;
28
+ const api = createApiClient(apiUrl);
26
29
  const limit = parseInt(options.limit, 10) || 20;
27
30
  const page = parseInt(options.page, 10) || 1;
28
31
 
@@ -59,9 +62,10 @@ listCommand
59
62
  console.log(`\nFound ${pagination.totalSkills ?? skills.length} skill(s):`);
60
63
  console.log('─'.repeat(60));
61
64
  skills.forEach((skill) => {
62
- const { name, version, downloads, category, status } = formatSkillDisplay(skill);
65
+ const { name, version, downloads, category, status, description } = formatSkillDisplay(skill);
63
66
  const statusStr = options.mine ? ` | ${status}` : '';
64
67
  console.log(` ${name}`);
68
+ console.log(` ${description}`);
65
69
  console.log(` Version: ${version} | Downloads: ${downloads} | Category: ${category}${statusStr}`);
66
70
  });
67
71
  if (pagination.totalPages > 1) {
@@ -12,8 +12,8 @@ loginCommand
12
12
  .option('-p, --password <password>', 'Password')
13
13
  .option('-t, --token <token>', 'Use access token directly (from web)')
14
14
  .option('--api-url <url>', 'API base URL (overrides config for this command)')
15
- .action(async (options) => {
16
- const apiUrl = normalizeApiUrl(options.apiUrl || getApiUrl());
15
+ .action(async (options, command) => {
16
+ const apiUrl = normalizeApiUrl(command.optsWithGlobals().apiUrl || getApiUrl());
17
17
 
18
18
  if (options.token) {
19
19
  setAuth({ token: options.token });
@@ -6,8 +6,8 @@ const logoutCommand = new Command('logout');
6
6
  logoutCommand
7
7
  .description('Logout from BotSkill')
8
8
  .option('--api-url <url>', 'API base URL (overrides config for this command)')
9
- .action(async (options) => {
10
- const apiUrl = normalizeApiUrl(options.apiUrl || getApiUrl());
9
+ .action(async (options, command) => {
10
+ const apiUrl = normalizeApiUrl(command.optsWithGlobals().apiUrl || getApiUrl());
11
11
  const refreshToken = getRefreshToken();
12
12
  if (refreshToken) {
13
13
  try {
@@ -0,0 +1,53 @@
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
+ console.log('[DRY RUN] Would pack:', dirPath);
33
+ console.log('[DRY RUN] Output:', options.output || path.join(dirPath, 'skill.zip'));
34
+ return;
35
+ }
36
+
37
+ try {
38
+ const outputPath = await packDirectory(dirPath, { output: options.output });
39
+ console.log('Packed successfully!');
40
+ console.log(`Output: ${outputPath}`);
41
+ console.log('\nUse "skm push" or "skm push -f <path>" to upload.');
42
+ } catch (err) {
43
+ if (err.message === 'NO_SKILL_MD') {
44
+ printSimpleError('SKILL.md not found', 'Create SKILL.md in the directory first');
45
+ } else if (err.message === 'FILE_NOT_FOUND') {
46
+ printSimpleError('Directory not found', dirPath);
47
+ } else {
48
+ printSimpleError(err.message || 'Pack failed');
49
+ }
50
+ }
51
+ });
52
+
53
+ export { packCommand };
@@ -4,14 +4,17 @@ 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
- .action(async (options) => {
16
+ .action(async (options, command) => {
17
+ const apiUrl = command.optsWithGlobals().apiUrl;
15
18
  if (!getToken()) {
16
19
  console.error('Not logged in. Run: skm login');
17
20
  process.exit(1);
@@ -31,14 +34,37 @@ publishCommand
31
34
  }
32
35
  }
33
36
 
34
- 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) {
35
61
  console.log('[DRY RUN] Would publish:', path.resolve(filePath));
36
62
  return;
37
63
  }
38
64
 
39
- console.log(`Publishing skill from ${path.basename(filePath)}...`);
65
+ console.log(`Publishing skill from ${path.basename(uploadPath)}...`);
40
66
  try {
41
- const skill = await uploadSkillFile(filePath, { apiUrl: options.apiUrl });
67
+ const skill = await uploadSkillFile(uploadPath, { apiUrl, configDir });
42
68
  console.log('Skill published successfully!');
43
69
  console.log(`Name: ${skill?.name}`);
44
70
  console.log(`Version: ${skill?.version || (skill?.versions?.[0]?.version)}`);
@@ -47,13 +73,19 @@ publishCommand
47
73
  if (err.message === 'NOT_LOGGED_IN') {
48
74
  printSimpleError('Not logged in', 'Run "skm login" first');
49
75
  } else if (err.message === 'FILE_NOT_FOUND') {
50
- printSimpleError('File not found', 'Check the path with --file <path>');
76
+ printSimpleError('File not found', uploadPath);
51
77
  } else {
52
78
  const msg = err.response?.data?.error || err.response?.data?.details?.[0];
53
79
  if (msg) err._overrideMsg = msg;
54
80
  if (err.response?.status === 401) err._overrideMsg = 'Token expired or invalid. Run "skm login" first.';
55
81
  printApiError(err, { prefix: 'Publish failed' });
56
82
  }
83
+ } finally {
84
+ if (tempPackPath) {
85
+ try {
86
+ await fs.remove(path.dirname(tempPackPath));
87
+ } catch (_) {}
88
+ }
57
89
  }
58
90
  });
59
91
 
@@ -3,15 +3,18 @@ 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
- .action(async (options) => {
16
+ .action(async (options, command) => {
17
+ const apiUrl = command.optsWithGlobals().apiUrl;
15
18
  if (!getToken()) {
16
19
  console.error('Not logged in. Run: skm login');
17
20
  process.exit(1);
@@ -31,14 +34,38 @@ pushCommand
31
34
  }
32
35
  }
33
36
 
34
- 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) {
35
62
  console.log('[DRY RUN] Would upload:', path.resolve(filePath));
36
63
  return;
37
64
  }
38
65
 
39
- console.log(`Pushing skill from ${path.basename(filePath)}...`);
66
+ console.log(`Pushing skill from ${path.basename(uploadPath)}...`);
40
67
  try {
41
- const skill = await uploadSkillFile(filePath, { apiUrl: options.apiUrl });
68
+ const skill = await uploadSkillFile(uploadPath, { apiUrl, configDir });
42
69
  console.log('Skill uploaded successfully!');
43
70
  console.log(`Name: ${skill?.name}`);
44
71
  console.log(`Version: ${skill?.version || (skill?.versions?.[0]?.version)}`);
@@ -47,13 +74,19 @@ pushCommand
47
74
  if (err.message === 'NOT_LOGGED_IN') {
48
75
  printSimpleError('Not logged in', 'Run "skm login" first');
49
76
  } else if (err.message === 'FILE_NOT_FOUND') {
50
- printSimpleError('File not found', 'Check the path with --file <path>');
77
+ printSimpleError('File not found', uploadPath);
51
78
  } else {
52
79
  const msg = err.response?.data?.error || err.response?.data?.details?.[0];
53
80
  if (msg) err._overrideMsg = msg;
54
81
  if (err.response?.status === 401) err._overrideMsg = 'Token expired or invalid. Run "skm login" first.';
55
82
  printApiError(err, { prefix: 'Push failed' });
56
83
  }
84
+ } finally {
85
+ if (tempPackPath) {
86
+ try {
87
+ await fs.remove(path.dirname(tempPackPath));
88
+ } catch (_) {}
89
+ }
57
90
  }
58
91
  });
59
92
 
@@ -9,7 +9,9 @@ function formatSkillDisplay(skill) {
9
9
  const version = skill.version || (skill.versions?.[0]?.version) || '—';
10
10
  const downloads = skill.downloads ?? 0;
11
11
  const category = skill.category || '—';
12
- return { displayName, version, downloads, category };
12
+ const desc = (skill.description || '').trim();
13
+ const description = desc.length > 80 ? desc.slice(0, 77) + '...' : desc || '—';
14
+ return { displayName, version, downloads, category, description };
13
15
  }
14
16
 
15
17
  const searchCommand = new Command('search');
@@ -20,8 +22,9 @@ searchCommand
20
22
  .option('-l, --limit <number>', 'Maximum number of results (default: 20)', '20')
21
23
  .option('-p, --page <number>', 'Page number for pagination (default: 1)', '1')
22
24
  .option('--api-url <url>', 'API base URL (overrides config for this command)')
23
- .action(async (query, options) => {
24
- const api = createApiClient(options.apiUrl);
25
+ .action(async (query, options, command) => {
26
+ const apiUrl = command.optsWithGlobals().apiUrl;
27
+ const api = createApiClient(apiUrl);
25
28
  const limit = parseInt(options.limit, 10) || 20;
26
29
  const page = parseInt(options.page, 10) || 1;
27
30
 
@@ -41,8 +44,9 @@ searchCommand
41
44
  console.log(`\nFound ${pagination.totalSkills ?? skills.length} skill(s) for "${query}":`);
42
45
  console.log('─'.repeat(60));
43
46
  skills.forEach((skill) => {
44
- const { displayName, version, downloads, category } = formatSkillDisplay(skill);
47
+ const { displayName, version, downloads, category, description } = formatSkillDisplay(skill);
45
48
  console.log(` ${displayName}`);
49
+ console.log(` ${description}`);
46
50
  console.log(` Version: ${version} | Downloads: ${downloads} | Category: ${category}`);
47
51
  });
48
52
  if (pagination.totalPages > 1) {
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';
@@ -28,13 +29,15 @@ const program = new Command();
28
29
  program
29
30
  .name('skm')
30
31
  .description('CLI tool for managing BotSkill - a platform for AI agent skills')
31
- .version(version);
32
+ .version(version)
33
+ .option('--api-url <url>', 'API base URL (overrides config for this command)');
32
34
 
33
35
  program.addCommand(initCommand);
34
36
  program.addCommand(loginCommand);
35
37
  program.addCommand(logoutCommand);
36
38
  program.addCommand(configCommand);
37
39
  program.addCommand(getCommand);
40
+ program.addCommand(packCommand);
38
41
  program.addCommand(pushCommand);
39
42
  program.addCommand(publishCommand);
40
43
  program.addCommand(listCommand);
@@ -0,0 +1,95 @@
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
+ 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
+ let outputPath;
76
+ if (opts.output) {
77
+ outputPath = path.resolve(opts.output);
78
+ } else if (opts.useTempDir) {
79
+ const { name, version } = await getConfigFromDir(resolved);
80
+ const safeName = toSafeId(opts.name ?? name);
81
+ const safeVersion = String(opts.version ?? version).replace(/[^a-zA-Z0-9.-]/g, '-');
82
+ const tmpDir = path.join(os.tmpdir(), `skm-pack-${Date.now()}-${Math.random().toString(36).slice(2)}`);
83
+ await fs.ensureDir(tmpDir);
84
+ outputPath = path.join(tmpDir, `${safeName}-${safeVersion}.zip`);
85
+ } else {
86
+ outputPath = path.join(resolved, 'skill.zip');
87
+ }
88
+ const excludeFilter = createExcludeFilter(opts.exclude);
89
+
90
+ const zip = new AdmZip();
91
+ zip.addLocalFolder(resolved, '', (p) => excludeFilter(p.replace(/\\/g, '/')));
92
+ zip.writeZip(outputPath);
93
+
94
+ return outputPath;
95
+ }
@@ -24,11 +24,50 @@ export async function findUploadFile(cwd = process.cwd()) {
24
24
  return null;
25
25
  }
26
26
 
27
+ /**
28
+ * Load skill.config.json from directory (same dir as file or parent)
29
+ */
30
+ export async function loadSkillConfig(filePath) {
31
+ const dir = path.dirname(path.resolve(filePath));
32
+ const configPath = path.join(dir, 'skill.config.json');
33
+ if (!(await fs.pathExists(configPath))) return null;
34
+ try {
35
+ return await fs.readJson(configPath);
36
+ } catch {
37
+ return null;
38
+ }
39
+ }
40
+
41
+ /**
42
+ * Append skill.config.json fields to FormData (version, category, license, tags, urls)
43
+ */
44
+ function appendConfigToForm(form, config) {
45
+ if (!config) return;
46
+ const fields = [
47
+ 'version',
48
+ 'category',
49
+ 'license',
50
+ 'repositoryUrl',
51
+ 'documentationUrl',
52
+ 'demoUrl',
53
+ ];
54
+ for (const key of fields) {
55
+ const val = config[key];
56
+ if (val !== undefined && val !== null && val !== '') {
57
+ form.append(key, Array.isArray(val) ? JSON.stringify(val) : String(val));
58
+ }
59
+ }
60
+ if (Array.isArray(config.tags) && config.tags.length > 0) {
61
+ form.append('tags', JSON.stringify(config.tags));
62
+ }
63
+ }
64
+
27
65
  /**
28
66
  * Upload skill file (SKILL.md, .zip, .tar.gz) to BotSkill
29
67
  * @param {string} filePath - Path to file
30
68
  * @param {Object} [opts] - 可选
31
69
  * @param {string} [opts.apiUrl] - 覆盖 API 地址(来自 --api-url)
70
+ * @param {string} [opts.configDir] - 指定读取 skill.config.json 的目录(默认与文件同目录)
32
71
  */
33
72
  export async function uploadSkillFile(filePath, opts = {}) {
34
73
  const token = getToken();
@@ -46,6 +85,15 @@ export async function uploadSkillFile(filePath, opts = {}) {
46
85
  filename: path.basename(filePath),
47
86
  });
48
87
 
88
+ const configDir = opts.configDir ?? path.dirname(path.resolve(filePath));
89
+ const configPath = path.join(configDir, 'skill.config.json');
90
+ if (await fs.pathExists(configPath)) {
91
+ try {
92
+ const config = await fs.readJson(configPath);
93
+ appendConfigToForm(form, config);
94
+ } catch (_) {}
95
+ }
96
+
49
97
  const api = createApiClient(opts.apiUrl);
50
98
  const res = await api.post('/skills/upload', form, {
51
99
  headers: form.getHeaders(),