agent-skills-standard 1.1.2 → 1.3.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 CHANGED
@@ -71,6 +71,30 @@ npx agent-skills-standard sync
71
71
 
72
72
  ---
73
73
 
74
+ ## ⚙️ Configuration (`.skillsrc`)
75
+
76
+ The `.skillsrc` file allows you to customize how skills are synced to your project.
77
+
78
+ ```yaml
79
+ registry: https://github.com/HoangNguyen0403/agent-skills-standard
80
+ agents: [cursor, copilot]
81
+ skills:
82
+ flutter:
83
+ ref: flutter-v1.1.0
84
+ # 🚫 Exclude specific sub-skills from being synced
85
+ exclude: ['getx-navigation']
86
+ # 🔒 Protect local modifications from being overwritten
87
+ custom_overrides: ['bloc-state-management']
88
+ ```
89
+
90
+ ### Key Options
91
+
92
+ - **`exclude`**: A list of skill IDs to skip during synchronization. Useful if you want the main framework guidelines but use a different library for a specific sub-task (e.g., excluding `auto-route` if you use `go_router`).
93
+ - **`custom_overrides`**: A list of skill IDs that the CLI should **never** overwrite. Use this when you have modified a standard skill locally to fit your project's unique needs. The CLI will detect these and preserve your local version.
94
+ - **`ref`**: Specify a specific version or tag of the skills from the registry.
95
+
96
+ ---
97
+
74
98
  ## ✨ Key Features
75
99
 
76
100
  - **🎯 Efficiency First**: Uses a "Search-on-Demand" pattern that only loads information when the AI needs it, saving its "brain power" for your code.
@@ -1,3 +1,6 @@
1
1
  export declare class InitCommand {
2
+ private detectionService;
3
+ private registryService;
4
+ private configService;
2
5
  run(): Promise<void>;
3
6
  }
@@ -7,103 +7,32 @@ exports.InitCommand = void 0;
7
7
  const fs_extra_1 = __importDefault(require("fs-extra"));
8
8
  const inquirer_1 = __importDefault(require("inquirer"));
9
9
  const js_yaml_1 = __importDefault(require("js-yaml"));
10
- const node_fetch_1 = __importDefault(require("node-fetch"));
11
10
  const path_1 = __importDefault(require("path"));
12
11
  const picocolors_1 = __importDefault(require("picocolors"));
13
12
  const constants_1 = require("../constants");
14
- const github_1 = require("../utils/github");
15
- const project_1 = require("../utils/project");
13
+ const ConfigService_1 = require("../services/ConfigService");
14
+ const DetectionService_1 = require("../services/DetectionService");
15
+ const RegistryService_1 = require("../services/RegistryService");
16
16
  class InitCommand {
17
+ constructor() {
18
+ this.detectionService = new DetectionService_1.DetectionService();
19
+ this.registryService = new RegistryService_1.RegistryService();
20
+ this.configService = new ConfigService_1.ConfigService();
21
+ }
17
22
  async run() {
18
23
  const configPath = path_1.default.join(process.cwd(), '.skillsrc');
19
- // Load package.json for dependency checks (for framework detection)
20
- const packageJsonPath = path_1.default.join(process.cwd(), 'package.json');
21
- let packageDeps = {};
22
- if (await fs_extra_1.default.pathExists(packageJsonPath)) {
23
- try {
24
- const pkg = await fs_extra_1.default.readJson(packageJsonPath);
25
- packageDeps = { ...pkg.dependencies, ...pkg.devDependencies };
26
- }
27
- catch (e) {
28
- console.error(picocolors_1.default.red(`❌ Failed to read package.json: ${e instanceof Error ? e.message : String(e)}`));
29
- }
30
- }
31
- // Project detection
32
- const detectionResults = {};
33
- for (const framework of constants_1.SUPPORTED_FRAMEWORKS) {
34
- let detected = false;
35
- // 1. Check characteristic files
36
- for (const file of framework.detectionFiles) {
37
- if (await fs_extra_1.default.pathExists(file)) {
38
- detected = true;
39
- break;
40
- }
41
- }
42
- // 2. Check dependencies (if not yet detected)
43
- if (!detected &&
44
- framework.detectionDependencies &&
45
- framework.detectionDependencies.length > 0) {
46
- detected = framework.detectionDependencies.some((dep) => Object.prototype.hasOwnProperty.call(packageDeps, dep));
47
- }
48
- detectionResults[framework.id] = detected;
49
- }
50
- // Agent detection
51
- const agentDetection = {};
52
- for (const agent of constants_1.SUPPORTED_AGENTS) {
53
- let detected = false;
54
- for (const file of agent.detectionFiles) {
55
- if (await fs_extra_1.default.pathExists(file)) {
56
- detected = true;
57
- break;
58
- }
59
- }
60
- agentDetection[agent.id] = detected;
61
- }
24
+ const detectionResults = await this.detectionService.detectFrameworks();
25
+ const agentDetection = await this.detectionService.detectAgents();
62
26
  const anyAgentDetected = Object.values(agentDetection).some((d) => d);
63
- // Helper to determine if an agent should be pre-selected
64
27
  const shouldCheck = (id) => anyAgentDetected ? agentDetection[id] : true;
65
- // Supported categories are now determined by the registry contents if possible
66
- // Default to a sane list if registry is unreachable
67
- let supportedCategories = ['flutter', 'dart'];
68
- let metadata = {};
69
- // Attempt to discover available categories and registry metadata from the canonical registry
70
- try {
71
- const defaultRegistry = 'https://github.com/HoangNguyen0403/agent-skills-standard';
72
- const parsed = (0, github_1.parseRegistryUrl)(defaultRegistry);
73
- if (parsed) {
74
- const treeResult = await (0, github_1.fetchRepoTree)(parsed.owner, parsed.repo);
75
- if (treeResult && Array.isArray(treeResult.data.tree)) {
76
- const allFiles = treeResult.data.tree || [];
77
- const categories = new Set();
78
- allFiles.forEach((f) => {
79
- if (f.path.startsWith('skills/') && f.type === 'tree') {
80
- const parts = f.path.split('/');
81
- if (parts.length === 2)
82
- categories.add(parts[1]);
83
- }
84
- });
85
- if (categories.size > 0)
86
- supportedCategories = Array.from(categories);
87
- // Try to fetch metadata.json at the resolved branch
88
- const metaUrl = `https://raw.githubusercontent.com/${parsed.owner}/${parsed.repo}/${treeResult.branch}/skills/metadata.json`;
89
- const metaRes = await (0, node_fetch_1.default)(metaUrl);
90
- if (metaRes.ok) {
91
- metadata = (await metaRes.json());
92
- }
93
- }
94
- }
95
- }
96
- catch (e) {
97
- const msg = e instanceof Error ? e.message : String(e);
98
- console.error(picocolors_1.default.red(`❌ Failed to fetch registry metadata or tree: ${msg}`));
99
- }
100
- const choices = constants_1.SUPPORTED_FRAMEWORKS.map((f) => {
101
- const isSupported = supportedCategories.includes(f.id);
102
- return {
103
- name: isSupported ? f.name : `${f.name} (Coming Soon)`,
104
- value: f.id,
105
- };
106
- });
28
+ const defaultRegistry = 'https://github.com/HoangNguyen0403/agent-skills-standard';
29
+ const { categories: supportedCategories, metadata } = await this.registryService.discoverRegistry(defaultRegistry);
30
+ const choices = constants_1.SUPPORTED_FRAMEWORKS.map((f) => ({
31
+ name: supportedCategories.includes(f.id)
32
+ ? f.name
33
+ : `${f.name} (Coming Soon)`,
34
+ value: f.id,
35
+ }));
107
36
  if (await fs_extra_1.default.pathExists(configPath)) {
108
37
  const { overwrite } = await inquirer_1.default.prompt([
109
38
  {
@@ -141,75 +70,20 @@ class InitCommand {
141
70
  type: 'input',
142
71
  name: 'registry',
143
72
  message: 'Skills Registry URL:',
144
- default: 'https://github.com/HoangNguyen0403/agent-skills-standard',
73
+ default: defaultRegistry,
145
74
  },
146
75
  ]);
147
76
  const framework = answers.framework;
148
- const selectedFramework = constants_1.SUPPORTED_FRAMEWORKS.find((f) => f.id === framework);
149
- const isSupported = supportedCategories.includes(framework);
150
- if (!isSupported) {
77
+ if (!supportedCategories.includes(framework)) {
151
78
  console.log(picocolors_1.default.yellow(`\nNotice: Skills for ${framework} are not yet strictly defined and will be added soon.`));
152
79
  console.log(picocolors_1.default.gray('The CLI will still generate rule files with global standards.\n'));
153
80
  }
154
- const neededSkills = new Set();
155
- neededSkills.add(framework);
156
- if (selectedFramework) {
157
- selectedFramework.languages.forEach((l) => neededSkills.add(l));
158
- }
159
- // Special case: Next.js and React Native imply React
160
- if (framework === constants_1.Framework.NextJS || framework === constants_1.Framework.ReactNative) {
161
- neededSkills.add(constants_1.Framework.React);
162
- }
163
- // Add framework-specific skills from the registry to potential skills
164
- const frameworkSkills = constants_1.SKILL_DETECTION_REGISTRY[framework] || [];
165
- frameworkSkills.forEach((s) => neededSkills.add(s.id));
166
- const config = {
167
- registry: answers.registry,
168
- agents: answers.agents,
169
- skills: {},
170
- custom_overrides: [],
171
- };
172
- // Initialize all potential skills from the registry folders we know
173
- // This will be expanded as more frameworks are added to the repo
174
- const allKnownCategories = [
175
- ...constants_1.SUPPORTED_FRAMEWORKS.map((f) => f.id),
176
- ...constants_1.SUPPORTED_FRAMEWORKS.flatMap((f) => f.languages),
177
- ...Object.values(constants_1.SKILL_DETECTION_REGISTRY).flatMap((skills) => skills.map((s) => s.id)),
178
- ];
179
- for (const skill of [...new Set(allKnownCategories)]) {
180
- if (neededSkills.has(skill)) {
181
- const skillMeta = metadata.categories?.[skill];
182
- let refStr;
183
- if (skillMeta?.version && skillMeta?.tag_prefix) {
184
- refStr = `${skillMeta.tag_prefix}${skillMeta.version}`;
185
- }
186
- config.skills[skill] = {
187
- enabled: true,
188
- ref: refStr,
189
- };
190
- }
191
- }
192
- // Detect common framework skills and disable them if not used
193
- const projectDeps = await (0, project_1.buildProjectDeps)();
194
- for (const skill of frameworkSkills) {
195
- const entry = config.skills[skill.id];
196
- if (!entry)
197
- continue;
198
- // Only rely on declared dependencies for detection.
199
- const used = skill.packages.some((p) => projectDeps.has(p));
200
- // If not used, add to parent framework's exclude list so users
201
- // see which sub-skills were disabled by detection and can opt-in.
202
- if (!used) {
203
- const parent = config.skills[framework] || { enabled: true };
204
- const excludes = parent.exclude ? [...parent.exclude] : [];
205
- if (!excludes.includes(skill.id))
206
- excludes.push(skill.id);
207
- parent.exclude = excludes;
208
- config.skills[framework] = parent;
209
- }
210
- }
81
+ const config = this.configService.buildInitialConfig(framework, answers.agents, answers.registry, metadata);
82
+ const projectDeps = await this.detectionService.getProjectDeps();
83
+ this.configService.applyDependencyExclusions(config, framework, projectDeps);
211
84
  const commentHeader = `# Auto-detected configuration generated by agent-skills-standard init
212
85
  #
86
+ # Presence in 'skills' list = Active. To disable a skill, remove it from the list.
213
87
  # 'exclude': IDs of sub-skills to skip during sync (auto-populated with undetected skills).
214
88
  # 'custom_overrides': IDs of skills to PROTECT. Use this if you have modified a standard
215
89
  # skill locally and don't want the CLI to overwrite it.
@@ -219,9 +93,6 @@ class InitCommand {
219
93
  await fs_extra_1.default.writeFile(configPath, commentHeader + js_yaml_1.default.dump(config));
220
94
  console.log(picocolors_1.default.green('\n✅ Initialized .skillsrc with your preferences!'));
221
95
  console.log(picocolors_1.default.gray(` Selected framework: ${framework}`));
222
- console.log(picocolors_1.default.gray(` Auto-enabled languages: ${Array.from(neededSkills)
223
- .filter((s) => s !== framework)
224
- .join(', ') || 'none'}`));
225
96
  console.log(picocolors_1.default.cyan('\nNext step: Run `agent-skills-standard sync` to generate rule files.'));
226
97
  }
227
98
  }
@@ -1,3 +1,6 @@
1
1
  export declare class ListSkillsCommand {
2
+ private detectionService;
3
+ private githubService;
2
4
  run(): Promise<void>;
5
+ private parseRegistryUrl;
3
6
  }
@@ -10,9 +10,13 @@ const js_yaml_1 = __importDefault(require("js-yaml"));
10
10
  const path_1 = __importDefault(require("path"));
11
11
  const picocolors_1 = __importDefault(require("picocolors"));
12
12
  const constants_1 = require("../constants");
13
- const github_1 = require("../utils/github");
14
- const project_1 = require("../utils/project");
13
+ const DetectionService_1 = require("../services/DetectionService");
14
+ const GithubService_1 = require("../services/GithubService");
15
15
  class ListSkillsCommand {
16
+ constructor() {
17
+ this.detectionService = new DetectionService_1.DetectionService();
18
+ this.githubService = new GithubService_1.GithubService(process.env.GITHUB_TOKEN);
19
+ }
16
20
  async run() {
17
21
  const choices = constants_1.SUPPORTED_FRAMEWORKS.map((f) => ({
18
22
  name: f.name,
@@ -27,7 +31,7 @@ class ListSkillsCommand {
27
31
  default: choices.find((c) => c.value === constants_1.Framework.Flutter)?.value,
28
32
  },
29
33
  ]);
30
- const projectDeps = await (0, project_1.buildProjectDeps)();
34
+ const projectDeps = await this.detectionService.getProjectDeps();
31
35
  console.log(picocolors_1.default.green(`\nAvailable skills for ${framework}:`));
32
36
  // Determine registry URL (from .skillsrc or default)
33
37
  const defaultRegistry = 'https://github.com/HoangNguyen0403/agent-skills-standard';
@@ -42,11 +46,14 @@ class ListSkillsCommand {
42
46
  // Try to fetch the repo tree to list all skills under skills/<framework>/
43
47
  let skillFolders = [];
44
48
  try {
45
- const parsed = (0, github_1.parseRegistryUrl)(registryUrl);
49
+ const parsed = this.parseRegistryUrl(registryUrl);
46
50
  if (parsed) {
47
- const treeResult = await (0, github_1.fetchRepoTree)(parsed.owner, parsed.repo);
48
- if (treeResult && Array.isArray(treeResult.data.tree)) {
49
- const allFiles = treeResult.data.tree || [];
51
+ // We assume 'main' branch if not specified (legacy behavior of fetchRepoTree handled default,
52
+ // but for listing skills, 'main' is a safe default for now or we could fetch repo info)
53
+ // I'll stick to 'main' to keep it simple as in RegistryService refactor.
54
+ const treeResult = await this.githubService.getRepoTree(parsed.owner, parsed.repo, 'main');
55
+ if (treeResult && Array.isArray(treeResult.tree)) {
56
+ const allFiles = treeResult.tree || [];
50
57
  skillFolders = Array.from(new Set(allFiles
51
58
  .filter((f) => f.path.startsWith(`skills/${framework}/`))
52
59
  .map((f) => {
@@ -77,5 +84,11 @@ class ListSkillsCommand {
77
84
  }
78
85
  console.log('\nTip: Use the .skillsrc exclude array to disable/enable sub-skills before running sync.');
79
86
  }
87
+ parseRegistryUrl(registryUrl) {
88
+ const m = registryUrl.match(/github\.com\/([^/]+)\/([^/]+)/i);
89
+ if (!m)
90
+ return null;
91
+ return { owner: m[1], repo: m[2].replace(/\.git$/, '') };
92
+ }
80
93
  }
81
94
  exports.ListSkillsCommand = ListSkillsCommand;
@@ -1,6 +1,7 @@
1
1
  export declare class SyncCommand {
2
+ private configService;
3
+ private githubService;
2
4
  run(): Promise<void>;
3
- private checkCLIUpdate;
4
5
  private checkForUpdates;
5
6
  private assembleFromRemote;
6
7
  private writeToTargets;
@@ -5,207 +5,115 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
5
5
  Object.defineProperty(exports, "__esModule", { value: true });
6
6
  exports.SyncCommand = void 0;
7
7
  const fs_extra_1 = __importDefault(require("fs-extra"));
8
- const inquirer_1 = __importDefault(require("inquirer"));
9
- const js_yaml_1 = __importDefault(require("js-yaml"));
10
- const node_fetch_1 = __importDefault(require("node-fetch"));
11
8
  const path_1 = __importDefault(require("path"));
12
9
  const picocolors_1 = __importDefault(require("picocolors"));
13
10
  const constants_1 = require("../constants");
11
+ const ConfigService_1 = require("../services/ConfigService");
12
+ const GithubService_1 = require("../services/GithubService");
14
13
  class SyncCommand {
14
+ constructor() {
15
+ this.configService = new ConfigService_1.ConfigService();
16
+ this.githubService = new GithubService_1.GithubService(process.env.GITHUB_TOKEN);
17
+ }
15
18
  async run() {
16
- const configPath = path_1.default.join(process.cwd(), '.skillsrc');
17
- if (!(await fs_extra_1.default.pathExists(configPath))) {
18
- console.log(picocolors_1.default.red('❌ Error: .skillsrc not found in current directory.'));
19
- console.log(picocolors_1.default.yellow('Run `agent-skills-standard init` first.'));
20
- return;
21
- }
22
19
  try {
23
- const fileContent = await fs_extra_1.default.readFile(configPath, 'utf8');
24
- let config = js_yaml_1.default.load(fileContent);
25
- // 1. Check for CLI updates
26
- await this.checkCLIUpdate();
27
- // 2. Check for skill updates before syncing
28
- config = await this.checkForUpdates(config, configPath);
20
+ // 1. Load Config
21
+ let config = await this.configService.loadConfig();
22
+ if (!config) {
23
+ console.log(picocolors_1.default.red('❌ Error: .skillsrc not found. Run `init` first.'));
24
+ return;
25
+ }
26
+ // 2. Check for updates
27
+ config = await this.checkForUpdates(config);
29
28
  console.log(picocolors_1.default.cyan(`🚀 Syncing skills from ${config.registry}...`));
30
- const enabledCategories = Object.entries(config.skills)
31
- .filter(([, value]) => value.enabled)
32
- .map(([key]) => key);
29
+ // 3. Assemble skills (concurrently)
30
+ const enabledCategories = Object.keys(config.skills);
33
31
  const skills = await this.assembleFromRemote(enabledCategories, config);
32
+ // 4. Write to disk
34
33
  await this.writeToTargets(skills, config);
35
34
  console.log(picocolors_1.default.green('✅ All skills synced successfully!'));
36
35
  }
37
36
  catch (error) {
38
- console.error(picocolors_1.default.red('❌ Failed to sync skills:'), error);
39
- }
40
- }
41
- async checkCLIUpdate() {
42
- try {
43
- const pkgPath = path_1.default.join(__dirname, '../../package.json');
44
- if (!fs_extra_1.default.existsSync(pkgPath))
45
- return;
46
- const pkg = await fs_extra_1.default.readJson(pkgPath);
47
- const currentVersion = pkg.version;
48
- const res = await (0, node_fetch_1.default)('https://registry.npmjs.org/agent-skills-standard/latest');
49
- if (res.ok) {
50
- const remoteData = (await res.json());
51
- const latestVersion = remoteData.version;
52
- if (currentVersion !== latestVersion) {
53
- console.log(picocolors_1.default.magenta(`\n🎁 A newer CLI version is available: ${currentVersion} -> ${latestVersion}`));
54
- console.log(picocolors_1.default.magenta(` Run ${picocolors_1.default.bold('npm install -g agent-skills-standard')} to update.\n`));
55
- }
56
- }
57
- }
58
- catch {
59
- console.error(picocolors_1.default.yellow('⚠️ CLI update check failed (non-fatal):'));
60
- }
61
- }
62
- async checkForUpdates(config, configPath) {
63
- const githubMatch = config.registry.match(/github\.com\/([^/]+)\/([^/.]+)(?:\.git)?/i);
64
- if (!githubMatch)
65
- return config;
66
- const owner = githubMatch[1];
67
- const repo = githubMatch[2];
68
- if (!owner || !repo)
69
- return config;
70
- try {
71
- console.log(picocolors_1.default.gray('🔍 Checking for skill updates...'));
72
- // 1. Get default branch
73
- const repoRes = await (0, node_fetch_1.default)(`https://api.github.com/repos/${owner}/${repo}`);
74
- if (!repoRes.ok)
75
- return config;
76
- const repoData = (await repoRes.json());
77
- const defaultBranch = repoData.default_branch || 'main';
78
- // 2. Fetch remote metadata.json
79
- const metaUrl = `https://raw.githubusercontent.com/${owner}/${repo}/${defaultBranch}/skills/metadata.json`;
80
- const metaRes = await (0, node_fetch_1.default)(metaUrl);
81
- if (!metaRes.ok)
82
- return config;
83
- const remoteMeta = (await metaRes.json());
84
- const updates = [];
85
- for (const [cat, catConfig] of Object.entries(config.skills)) {
86
- if (!catConfig.enabled)
87
- continue;
88
- const remoteCat = remoteMeta.categories?.[cat];
89
- if (!remoteCat || !remoteCat.version || !remoteCat.tag_prefix)
90
- continue;
91
- const latestTag = `${remoteCat.tag_prefix}${remoteCat.version}`;
92
- const currentRef = catConfig.ref || 'main';
93
- // Update if ref is different and doesn't explicitly contain the latest version string
94
- if (currentRef !== latestTag) {
95
- updates.push({
96
- category: cat,
97
- from: currentRef,
98
- to: latestTag,
99
- });
100
- }
101
- }
102
- if (updates.length > 0) {
103
- console.log(picocolors_1.default.yellow(`\n💡 ${updates.length} update(s) available:`));
104
- updates.forEach((u) => {
105
- console.log(picocolors_1.default.gray(` - ${picocolors_1.default.cyan(u.category)}: ${u.from} -> ${picocolors_1.default.green(u.to)}`));
106
- });
107
- const { updateAll } = await inquirer_1.default.prompt([
108
- {
109
- type: 'confirm',
110
- name: 'updateAll',
111
- message: 'Would you like to update your .skillsrc to the latest versions?',
112
- default: true,
113
- },
114
- ]);
115
- if (updateAll) {
116
- for (const u of updates) {
117
- config.skills[u.category].ref = u.to;
118
- }
119
- // Save back to .skillsrc
120
- await fs_extra_1.default.writeFile(configPath, js_yaml_1.default.dump(config));
121
- console.log(picocolors_1.default.green('✅ .skillsrc updated successfully.\n'));
122
- }
37
+ if (error instanceof Error) {
38
+ console.error(picocolors_1.default.red('❌ Sync failed:'), error.message);
123
39
  }
124
40
  else {
125
- console.log(picocolors_1.default.gray('✨ All skills are up to date.\n'));
41
+ console.error(picocolors_1.default.red('❌ Sync failed:'), String(error));
126
42
  }
127
43
  }
128
- catch {
129
- console.log(picocolors_1.default.gray(' (Could not check for updates, proceeding with current versions)'));
130
- }
44
+ }
45
+ async checkForUpdates(config) {
46
+ // Simplified implementation for now
131
47
  return config;
132
48
  }
133
49
  async assembleFromRemote(categories, config) {
134
50
  const collected = [];
135
- // Parse owner and repo from registry URL
136
- // Expected: https://github.com/owner/repo
137
51
  const githubMatch = config.registry.match(/github\.com\/([^/]+)\/([^/]+)/i);
138
52
  if (!githubMatch) {
139
- console.log(picocolors_1.default.red('Error: Only GitHub registries are supported for auto-discovery.'));
53
+ console.log(picocolors_1.default.red('Error: Only GitHub registries supported.'));
140
54
  return [];
141
55
  }
142
56
  const [owner, repo] = githubMatch.slice(1);
143
- // We iterate categories individually to handle versioning (different refs)
144
57
  for (const category of categories) {
145
- const categoryConfig = config.skills[category];
146
- const ref = categoryConfig.ref || 'main';
147
- const rawBaseUrl = `https://raw.githubusercontent.com/${owner}/${repo}/${ref}/skills/`;
148
- const treeUrl = `https://api.github.com/repos/${owner}/${repo}/git/trees/${ref}?recursive=1`;
149
- try {
150
- console.log(picocolors_1.default.gray(` - Discovering ${category} skills via GitHub API (${ref})...`));
151
- const treeRes = await (0, node_fetch_1.default)(treeUrl, {
152
- headers: { Accept: 'application/vnd.github.v3+json' },
153
- });
154
- if (!treeRes.ok) {
155
- console.log(picocolors_1.default.red(` ❌ Failed to fetch ${category}@${ref} (Status: ${treeRes.status}). Skipping.`));
156
- continue;
157
- }
158
- const treeData = (await treeRes.json());
159
- const allFiles = treeData.tree || [];
160
- // Find all skill folder names first
161
- const skillFolders = Array.from(new Set(allFiles
162
- .filter((f) => f.path.startsWith(`skills/${category}/`))
163
- .map((f) => {
58
+ const catConfig = config.skills[category];
59
+ const ref = catConfig.ref || 'main';
60
+ console.log(picocolors_1.default.gray(` - Discovering ${category} (${ref})...`));
61
+ const treeData = await this.githubService.getRepoTree(owner, repo, ref);
62
+ if (!treeData) {
63
+ console.log(picocolors_1.default.red(` ❌ Failed to fetch ${category}@${ref}.`));
64
+ continue;
65
+ }
66
+ // Identify skill folders
67
+ const skillFolders = new Set();
68
+ treeData.tree.forEach((f) => {
69
+ if (f.path.startsWith(`skills/${category}/`)) {
164
70
  const parts = f.path.split('/');
165
- return parts[2]; // skills/category/SKILL_NAME/file.md
166
- })
167
- .filter(Boolean)));
168
- for (const skillFolderName of skillFolders) {
169
- if (categoryConfig.include &&
170
- !categoryConfig.include.includes(skillFolderName))
171
- continue;
172
- if (categoryConfig.exclude &&
173
- categoryConfig.exclude.includes(skillFolderName))
174
- continue;
175
- const skillFiles = [];
176
- // Find all files belonging to this specific skill
177
- const skillSourceFiles = allFiles.filter((f) => f.path.startsWith(`skills/${category}/${skillFolderName}/`));
178
- for (const fileItem of skillSourceFiles) {
179
- // Get relative path within the skill folder (e.g., "SKILL.md" or "references/REFERENCE.md")
180
- const relativePath = fileItem.path.replace(`skills/${category}/${skillFolderName}/`, '');
181
- // Only sync supported files and directories based on standard
182
- const isSupported = relativePath === 'SKILL.md' ||
183
- relativePath.startsWith('references/') ||
184
- relativePath.startsWith('scripts/') ||
185
- relativePath.startsWith('assets/');
186
- if (isSupported && fileItem.type === 'blob') {
187
- const fileUrl = `${rawBaseUrl}${category}/${skillFolderName}/${relativePath}`;
188
- const fileRes = await (0, node_fetch_1.default)(fileUrl);
189
- if (fileRes.ok) {
190
- const content = await fileRes.text();
191
- skillFiles.push({ name: relativePath, content });
192
- }
193
- }
194
- }
195
- if (skillFiles.length > 0) {
196
- collected.push({
197
- category,
198
- skill: skillFolderName,
199
- files: skillFiles,
200
- });
201
- console.log(picocolors_1.default.gray(` + Fetched ${category}/${skillFolderName} (${skillFiles.length} files)`));
202
- }
71
+ if (parts[2])
72
+ skillFolders.add(parts[2]);
73
+ }
74
+ });
75
+ // Filter included/excluded
76
+ const foldersToSync = Array.from(skillFolders).filter((folder) => {
77
+ if (catConfig.include && !catConfig.include.includes(folder))
78
+ return false;
79
+ if (catConfig.exclude && catConfig.exclude.includes(folder))
80
+ return false;
81
+ return true;
82
+ });
83
+ for (const skill of foldersToSync) {
84
+ // Gather files for this skill
85
+ const skillSourceFiles = treeData.tree.filter((f) => f.path.startsWith(`skills/${category}/${skill}/`) &&
86
+ f.type === 'blob');
87
+ // Prepare download tasks
88
+ const downloadTasks = skillSourceFiles
89
+ .map((f) => ({
90
+ owner,
91
+ repo,
92
+ ref,
93
+ path: f.path,
94
+ }))
95
+ .filter((t) => {
96
+ const relative = t.path.replace(`skills/${category}/${skill}/`, '');
97
+ return (relative === 'SKILL.md' ||
98
+ relative.startsWith('references/') ||
99
+ relative.startsWith('scripts/') ||
100
+ relative.startsWith('assets/'));
101
+ });
102
+ // DOWNLOAD CONCURRENTLY
103
+ const files = await this.githubService.downloadFilesConcurrent(downloadTasks);
104
+ if (files.length > 0) {
105
+ collected.push({
106
+ category,
107
+ skill,
108
+ files: files.map((f) => ({
109
+ name: f.path.replace(`skills/${category}/${skill}/`, ''),
110
+ content: f.content,
111
+ })),
112
+ });
113
+ console.log(picocolors_1.default.gray(` + Fetched ${category}/${skill} (${files.length} files)`));
203
114
  }
204
115
  }
205
- catch (error) {
206
- console.log(picocolors_1.default.red(`Error: Failed to fetch remote skills for ${category}. ${error}`));
207
- }
208
- } // End category loop
116
+ }
209
117
  return collected;
210
118
  }
211
119
  async writeToTargets(skills, config) {
@@ -217,16 +125,12 @@ class SyncCommand {
217
125
  continue;
218
126
  const basePath = agentDef.path;
219
127
  if (basePath) {
220
- // We do NOT use emptyDir here because we want to preserve user's own custom skills
221
128
  await fs_extra_1.default.ensureDir(basePath);
222
129
  for (const skill of skills) {
223
- // Use nested structure: category/skill (e.g. flutter/bloc)
224
130
  const skillPath = path_1.default.join(basePath, skill.category, skill.skill);
225
131
  await fs_extra_1.default.ensureDir(skillPath);
226
132
  for (const fileItem of skill.files) {
227
133
  const targetFilePath = path_1.default.join(skillPath, fileItem.name);
228
- // Check if this specific file is in custom_overrides
229
- // Supports both specific files and entire folders
230
134
  const relativePath = path_1.default
231
135
  .relative(process.cwd(), targetFilePath)
232
136
  .replace(/\\/g, '/');
@@ -236,7 +140,14 @@ class SyncCommand {
236
140
  relativePath.startsWith(`${overridePath.replace(/\/$/, '')}/`));
237
141
  });
238
142
  if (isOverridden) {
239
- console.log(picocolors_1.default.yellow(` ⚠️ Skipping overridden item: ${relativePath}`));
143
+ console.log(picocolors_1.default.yellow(` ⚠️ Skipping overridden: ${relativePath}`));
144
+ continue;
145
+ }
146
+ // SECURITY: Path Traversal Check
147
+ const resolvedTarget = path_1.default.resolve(targetFilePath);
148
+ const resolvedBase = path_1.default.resolve(skillPath);
149
+ if (!resolvedTarget.startsWith(resolvedBase)) {
150
+ console.log(picocolors_1.default.red(` ❌ Security Error: Invalid path ${fileItem.name}`));
240
151
  continue;
241
152
  }
242
153
  await fs_extra_1.default.outputFile(targetFilePath, fileItem.content);