acp-vscode 0.3.9 → 0.4.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.
@@ -5,6 +5,7 @@ function makeCli() {
5
5
  _action: null,
6
6
  command() { return cli; },
7
7
  option() { return cli; },
8
+ example() { return cli; },
8
9
  action(fn) { cli._action = fn; return cli; }
9
10
  };
10
11
  return cli;
@@ -0,0 +1,266 @@
1
+ const fs = require('fs-extra');
2
+ const path = require('path');
3
+ const os = require('os');
4
+ const { installFiles, removeFiles } = require('../src/installer');
5
+
6
+ describe('skills as folders', () => {
7
+ test('installFiles installs skills as folders in workspace', async () => {
8
+ const tmp = path.join(os.tmpdir(), `acp-skills-${Date.now()}`);
9
+ await fs.ensureDir(tmp);
10
+
11
+ const skillContent = `---
12
+ name: test-skill
13
+ description: A test skill for demonstration
14
+ ---
15
+
16
+ # Test Skill
17
+
18
+ This is a test skill.`;
19
+
20
+ const items = [
21
+ { id: 'test-skill', name: 'Test Skill', content: skillContent }
22
+ ];
23
+
24
+ const dest = await installFiles({ items, type: 'skills', target: 'workspace', workspaceDir: tmp });
25
+
26
+ // Verify the destination is .github/skills
27
+ expect(dest).toBe(path.join(tmp, '.github', 'skills'));
28
+ expect(await fs.pathExists(dest)).toBe(true);
29
+
30
+ // Verify the skill folder was created
31
+ const skillFolder = path.join(dest, 'test-skill');
32
+ expect(await fs.pathExists(skillFolder)).toBe(true);
33
+ expect((await fs.stat(skillFolder)).isDirectory()).toBe(true);
34
+
35
+ // Verify SKILL.md exists in the skill folder
36
+ const skillMdPath = path.join(skillFolder, 'SKILL.md');
37
+ expect(await fs.pathExists(skillMdPath)).toBe(true);
38
+
39
+ // Verify the content is correct
40
+ const content = await fs.readFile(skillMdPath, 'utf8');
41
+ expect(content).toBe(skillContent);
42
+
43
+ await fs.remove(tmp);
44
+ });
45
+
46
+ test('installFiles installs skills as folders in user profile', async () => {
47
+ const tmp = path.join(os.tmpdir(), `acp-skills-user-${Date.now()}`);
48
+ await fs.ensureDir(tmp);
49
+ const origHome = process.env.HOME;
50
+ process.env.HOME = tmp;
51
+
52
+ const skillContent = `---
53
+ name: user-skill
54
+ description: A user skill
55
+ ---
56
+
57
+ # User Skill
58
+
59
+ This skill is for the user profile.`;
60
+
61
+ try {
62
+ const items = [
63
+ { id: 'user-skill', name: 'User Skill', content: skillContent }
64
+ ];
65
+
66
+ const dest = await installFiles({ items, type: 'skills', target: 'user' });
67
+
68
+ // Verify the destination is ~/.copilot/skills
69
+ expect(dest).toBe(path.join(tmp, '.copilot', 'skills'));
70
+ expect(await fs.pathExists(dest)).toBe(true);
71
+
72
+ // Verify the skill folder was created
73
+ const skillFolder = path.join(dest, 'user-skill');
74
+ expect(await fs.pathExists(skillFolder)).toBe(true);
75
+ const skillFolderStat = await fs.stat(skillFolder);
76
+ expect(skillFolderStat.isDirectory()).toBe(true);
77
+
78
+ // Verify SKILL.md exists
79
+ const skillMdPath = path.join(skillFolder, 'SKILL.md');
80
+ expect(await fs.pathExists(skillMdPath)).toBe(true);
81
+
82
+ // Verify the content is correct
83
+ const content = await fs.readFile(skillMdPath, 'utf8');
84
+ expect(content).toBe(skillContent);
85
+ } finally {
86
+ if (origHome === undefined) delete process.env.HOME;
87
+ else process.env.HOME = origHome;
88
+ await fs.remove(tmp);
89
+ }
90
+ });
91
+
92
+ test('installFiles handles duplicate skill IDs with repo prefixing', async () => {
93
+ const tmp = path.join(os.tmpdir(), `acp-skills-dupe-${Date.now()}`);
94
+ await fs.ensureDir(tmp);
95
+
96
+ const items = [
97
+ { id: 'shared', name: 'Shared One', repo: 'r1', content: 'Content from r1' },
98
+ { id: 'shared', name: 'Shared Two', repo: 'r2', content: 'Content from r2' }
99
+ ];
100
+
101
+ const dest = await installFiles({ items, type: 'skills', target: 'workspace', workspaceDir: tmp });
102
+
103
+ // Verify both skill folders exist with repo prefixes
104
+ expect(await fs.pathExists(path.join(dest, 'r1-shared'))).toBe(true);
105
+ expect(await fs.pathExists(path.join(dest, 'r2-shared'))).toBe(true);
106
+
107
+ // Verify the content is different for each
108
+ const content1 = await fs.readFile(path.join(dest, 'r1-shared', 'SKILL.md'), 'utf8');
109
+ const content2 = await fs.readFile(path.join(dest, 'r2-shared', 'SKILL.md'), 'utf8');
110
+
111
+ expect(content1).not.toBe(content2);
112
+ expect(content1).toContain('Content from r1');
113
+ expect(content2).toContain('Content from r2');
114
+
115
+ await fs.remove(tmp);
116
+ });
117
+
118
+ test('removeFiles removes skill folders from workspace', async () => {
119
+ const tmp = path.join(os.tmpdir(), `acp-skills-remove-${Date.now()}`);
120
+ const base = path.join(tmp, '.github', 'skills');
121
+ await fs.ensureDir(base);
122
+
123
+ // Create skill folders
124
+ const skill1Dir = path.join(base, 'skill1');
125
+ const skill2Dir = path.join(base, 'skill2');
126
+ await fs.ensureDir(skill1Dir);
127
+ await fs.ensureDir(skill2Dir);
128
+ await fs.writeFile(path.join(skill1Dir, 'SKILL.md'), 'Skill 1');
129
+ await fs.writeFile(path.join(skill2Dir, 'SKILL.md'), 'Skill 2');
130
+
131
+ // Remove skill1
132
+ const removed = await removeFiles({ names: ['skill1'], type: 'skills', target: 'workspace', workspaceDir: tmp });
133
+
134
+ expect(removed).toBe(1);
135
+ expect(await fs.pathExists(skill1Dir)).toBe(false);
136
+ expect(await fs.pathExists(skill2Dir)).toBe(true);
137
+
138
+ await fs.remove(tmp);
139
+ });
140
+
141
+ test('removeFiles removes skill folders from user profile', async () => {
142
+ const tmp = path.join(os.tmpdir(), `acp-skills-remove-user-${Date.now()}`);
143
+ const origHome = process.env.HOME;
144
+ process.env.HOME = tmp;
145
+
146
+ try {
147
+ const base = path.join(tmp, '.copilot', 'skills');
148
+ await fs.ensureDir(base);
149
+
150
+ // Create skill folders
151
+ const skill1Dir = path.join(base, 'user-skill1');
152
+ const skill2Dir = path.join(base, 'user-skill2');
153
+ await fs.ensureDir(skill1Dir);
154
+ await fs.ensureDir(skill2Dir);
155
+ await fs.writeFile(path.join(skill1Dir, 'SKILL.md'), 'User Skill 1');
156
+ await fs.writeFile(path.join(skill2Dir, 'SKILL.md'), 'User Skill 2');
157
+
158
+ // Remove skill1
159
+ const removed = await removeFiles({ names: ['user-skill1'], type: 'skills', target: 'user' });
160
+
161
+ expect(removed).toBe(1);
162
+ expect(await fs.pathExists(skill1Dir)).toBe(false);
163
+ expect(await fs.pathExists(skill2Dir)).toBe(true);
164
+ } finally {
165
+ if (origHome === undefined) delete process.env.HOME;
166
+ else process.env.HOME = origHome;
167
+ await fs.remove(tmp);
168
+ }
169
+ });
170
+
171
+ test('removeFiles handles repo-qualified skill names', async () => {
172
+ const tmp = path.join(os.tmpdir(), `acp-skills-remove-qualified-${Date.now()}`);
173
+ const base = path.join(tmp, '.github', 'skills');
174
+ await fs.ensureDir(base);
175
+
176
+ // Create repo-prefixed skill folders
177
+ const skill1Dir = path.join(base, 'r1-shared');
178
+ const skill2Dir = path.join(base, 'r2-shared');
179
+ await fs.ensureDir(skill1Dir);
180
+ await fs.ensureDir(skill2Dir);
181
+ await fs.writeFile(path.join(skill1Dir, 'SKILL.md'), 'Skill from r1');
182
+ await fs.writeFile(path.join(skill2Dir, 'SKILL.md'), 'Skill from r2');
183
+
184
+ // Remove with repo-qualified name
185
+ const removed = await removeFiles({ names: ['r1:shared'], type: 'skills', target: 'workspace', workspaceDir: tmp });
186
+
187
+ expect(removed).toBe(1);
188
+ expect(await fs.pathExists(skill1Dir)).toBe(false);
189
+ expect(await fs.pathExists(skill2Dir)).toBe(true);
190
+
191
+ await fs.remove(tmp);
192
+ });
193
+
194
+ test('installFiles copies all files in skill folder including supporting files', async () => {
195
+ const tmp = path.join(os.tmpdir(), `acp-skills-supporting-${Date.now()}`);
196
+ await fs.ensureDir(tmp);
197
+
198
+ const skillContent = `---
199
+ name: complete-skill
200
+ description: A skill with supporting files
201
+ ---
202
+
203
+ # Complete Skill
204
+
205
+ This skill has supporting files.`;
206
+
207
+ const referenceContent = `# Reference Guide
208
+
209
+ This is detailed reference material.`;
210
+
211
+ const scriptContent = `#!/bin/bash
212
+ echo "This is a script"`;
213
+
214
+ const items = [
215
+ {
216
+ id: 'complete-skill',
217
+ name: 'Complete Skill',
218
+ folderPath: 'skills/complete-skill',
219
+ files: [
220
+ { path: 'skills/complete-skill/SKILL.md', url: 'http://example.com/skills/complete-skill/SKILL.md' },
221
+ { path: 'skills/complete-skill/references/REFERENCE.md', url: 'http://example.com/skills/complete-skill/references/REFERENCE.md' },
222
+ { path: 'skills/complete-skill/scripts/example.sh', url: 'http://example.com/skills/complete-skill/scripts/example.sh' }
223
+ ]
224
+ }
225
+ ];
226
+
227
+ // Mock axios to return file contents
228
+ const axios = require('axios');
229
+ const originalAxios = axios.get;
230
+ axios.get = jest.fn((url) => {
231
+ if (url === 'http://example.com/skills/complete-skill/SKILL.md') {
232
+ return Promise.resolve({ data: skillContent });
233
+ } else if (url === 'http://example.com/skills/complete-skill/references/REFERENCE.md') {
234
+ return Promise.resolve({ data: referenceContent });
235
+ } else if (url === 'http://example.com/skills/complete-skill/scripts/example.sh') {
236
+ return Promise.resolve({ data: scriptContent });
237
+ }
238
+ return Promise.reject(new Error('Unknown URL'));
239
+ });
240
+
241
+ try {
242
+ const dest = await installFiles({ items, type: 'skills', target: 'workspace', workspaceDir: tmp });
243
+
244
+ // Verify the skill folder was created
245
+ const skillFolder = path.join(dest, 'complete-skill');
246
+ expect(await fs.pathExists(skillFolder)).toBe(true);
247
+
248
+ // Verify all files exist
249
+ expect(await fs.pathExists(path.join(skillFolder, 'SKILL.md'))).toBe(true);
250
+ expect(await fs.pathExists(path.join(skillFolder, 'references', 'REFERENCE.md'))).toBe(true);
251
+ expect(await fs.pathExists(path.join(skillFolder, 'scripts', 'example.sh'))).toBe(true);
252
+
253
+ // Verify content is correct
254
+ const skillMd = await fs.readFile(path.join(skillFolder, 'SKILL.md'), 'utf8');
255
+ const refMd = await fs.readFile(path.join(skillFolder, 'references', 'REFERENCE.md'), 'utf8');
256
+ const script = await fs.readFile(path.join(skillFolder, 'scripts', 'example.sh'), 'utf8');
257
+
258
+ expect(skillMd).toContain('complete-skill');
259
+ expect(refMd).toContain('Reference Guide');
260
+ expect(script).toContain('This is a script');
261
+ } finally {
262
+ axios.get = originalAxios;
263
+ await fs.remove(tmp);
264
+ }
265
+ });
266
+ });
@@ -9,6 +9,6 @@ test('searchIndex returns results with repo-qualified ids when conflicts exist',
9
9
  };
10
10
  const res = searchIndex(idx, 'shared');
11
11
  expect(res.length).toBe(1);
12
- expect(res[0].id).toBe('r1:shared');
12
+ expect(res[0].id).toBe('r1:prompt:shared');
13
13
  expect(res[0].type).toBe('prompt');
14
14
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "acp-vscode",
3
- "version": "0.3.9",
3
+ "version": "0.4.0",
4
4
  "description": "CLI to install GitHub Awesome Copilot agents, prompts, instructions, and skills into VS Code workspace or user profile",
5
5
  "bin": {
6
6
  "acp-vscode": "./bin/acp-vscode.js"
@@ -66,8 +66,9 @@ async function performInstall({ target, type, names, options, workspaceDir = pro
66
66
  const ambiguous = [];
67
67
  for (const given of names) {
68
68
  const g = (given || '').toLowerCase();
69
- // support repo-qualified id: <repo_id>:<file_id>
69
+ // support repo-qualified id: <repo>:<type>:<id> or <repo>:<id> (legacy)
70
70
  const parts = given && given.includes(':') ? given.split(':') : null;
71
+ const repoTypeQualified = parts && parts.length === 3;
71
72
  const repoQualified = parts && parts.length === 2;
72
73
  // collect exact matches across types
73
74
  // collect exact matches across types, grouping by type
@@ -77,7 +78,12 @@ async function performInstall({ target, type, names, options, workspaceDir = pro
77
78
  for (const it of arr) {
78
79
  const id = (it.id || it.name || '').toLowerCase();
79
80
  const name = (it.name || it.id || '').toLowerCase();
80
- if (repoQualified) {
81
+ if (repoTypeQualified) {
82
+ const [r, typeStr, fid] = parts;
83
+ // type from ID should match current type (singular form)
84
+ const typeSingular = tt === 'chatmodes' ? 'chatmode' : tt === 'agents' ? 'agent' : tt === 'instructions' ? 'instruction' : tt === 'prompts' ? 'prompt' : 'skill';
85
+ if (it.repo === r && typeStr === typeSingular && (id === fid.toLowerCase() || name === fid.toLowerCase())) exactMatches.push({ type: tt, item: it });
86
+ } else if (repoQualified) {
81
87
  const [r, fid] = parts;
82
88
  if (it.repo === r && (id === fid.toLowerCase() || name === fid.toLowerCase())) exactMatches.push({ type: tt, item: it });
83
89
  } else {
@@ -115,7 +121,11 @@ async function performInstall({ target, type, names, options, workspaceDir = pro
115
121
  for (const it of arr) {
116
122
  const id = (it.id || it.name || '').toLowerCase();
117
123
  const name = (it.name || it.id || '').toLowerCase();
118
- if (repoQualified) {
124
+ if (repoTypeQualified) {
125
+ const [r, typeStr, fid] = parts;
126
+ const typeSingular = tt === 'chatmodes' ? 'chatmode' : tt === 'agents' ? 'agent' : tt === 'instructions' ? 'instruction' : tt === 'prompts' ? 'prompt' : 'skill';
127
+ if (it.repo === r && typeStr === typeSingular && (id.startsWith(fid.toLowerCase()) || name.startsWith(fid.toLowerCase()))) startsMatches.push({ type: tt, item: it });
128
+ } else if (repoQualified) {
119
129
  const [r, fid] = parts;
120
130
  if (it.repo === r && (id.startsWith(fid.toLowerCase()) || name.startsWith(fid.toLowerCase()))) startsMatches.push({ type: tt, item: it });
121
131
  } else {
@@ -159,11 +169,16 @@ async function performInstall({ target, type, names, options, workspaceDir = pro
159
169
  for (const given of names) {
160
170
  const g = (given || '').toLowerCase();
161
171
  const parts = given && given.includes(':') ? given.split(':') : null;
172
+ const repoTypeQualified = parts && parts.length === 3;
162
173
  const repoQualified = parts && parts.length === 2;
163
174
  const exact = items.filter(it => {
164
175
  const id = (it.id || it.name || '').toLowerCase();
165
176
  const name = (it.name || it.id || '').toLowerCase();
166
- if (repoQualified) {
177
+ if (repoTypeQualified) {
178
+ const [r, typeStr, fid] = parts;
179
+ const typeSingular = t === 'chatmodes' ? 'chatmode' : t === 'agents' ? 'agent' : t === 'instructions' ? 'instruction' : t === 'prompts' ? 'prompt' : 'skill';
180
+ return it.repo === r && typeStr === typeSingular && (id === fid.toLowerCase() || name === fid.toLowerCase());
181
+ } else if (repoQualified) {
167
182
  const [r, fid] = parts;
168
183
  return it.repo === r && (id === fid.toLowerCase() || name === fid.toLowerCase());
169
184
  }
@@ -181,7 +196,11 @@ async function performInstall({ target, type, names, options, workspaceDir = pro
181
196
  const starts = items.filter(it => {
182
197
  const id = (it.id || it.name || '').toLowerCase();
183
198
  const name = (it.name || it.id || '').toLowerCase();
184
- if (repoQualified) {
199
+ if (repoTypeQualified) {
200
+ const [r, typeStr, fid] = parts;
201
+ const typeSingular = t === 'chatmodes' ? 'chatmode' : t === 'agents' ? 'agent' : t === 'instructions' ? 'instruction' : t === 'prompts' ? 'prompt' : 'skill';
202
+ return it.repo === r && typeStr === typeSingular && (id.startsWith(fid.toLowerCase()) || name.startsWith(fid.toLowerCase()));
203
+ } else if (repoQualified) {
185
204
  const [r, fid] = parts;
186
205
  return it.repo === r && (id.startsWith(fid.toLowerCase()) || name.startsWith(fid.toLowerCase()));
187
206
  }
@@ -259,27 +278,41 @@ async function performInstall({ target, type, names, options, workspaceDir = pro
259
278
  function installCommand(cli) {
260
279
  // Change signature so that names are variadic and type is provided via option to
261
280
  // avoid the ambiguity where the second positional arg would be parsed as `type`.
262
- cli.command('install <target> [names...]', 'Install items into workspace or user profile. target: workspace|user. type: prompts|chatmodes|agents|instructions|skills|all')
263
- .option('-t, --type <type>', 'Specify type: prompts|chatmodes|agents|instructions|skills|all')
281
+ cli.command('install <target> [names...]', 'Install prompts, chatmodes, agents, instructions, or skills')
282
+ .option('-t, --type <type>', 'Specify type: prompts|chatmodes|agents|instructions|skills|all (default: all)')
264
283
  // Note: --refresh is intentionally not a per-command option for install; use only with list/search
265
284
  .option('--referesh', "Alias for --refresh (typo alias)")
266
- .option('--dry-run', 'Show what would be installed without writing files')
267
- .action(async (target, names, options) => {
268
- // names may be undefined or an array. Support legacy positional type in case
269
- // the user still passed it as the first name (e.g. `install workspace prompts p1`).
270
- const TYPES = ['prompts','chatmodes','agents','instructions','skills','all'];
271
- let type = options.type;
272
- // Normalize names to an array. Some CLI parsers may provide a single
273
- // name as a string instead of a one-element array. Preserve values.
274
- let nm;
275
- if (names === undefined || names === null) nm = [];
276
- else if (Array.isArray(names)) nm = names.slice();
277
- else nm = [names];
278
- if (!type && nm.length > 0 && TYPES.includes(nm[0])) {
279
- type = nm.shift();
280
- }
281
- await performInstall({ target, type, names: nm, options, workspaceDir: process.cwd() });
282
- });
285
+ .option('--dry-run', 'Show what would be installed without writing files')
286
+ .example('acp-vscode install workspace # Install interactively to workspace')
287
+ .example('acp-vscode install user --type prompts # Install all prompts to user profile')
288
+ .example('acp-vscode install prompts # Install all prompts to workspace')
289
+ .example("acp-vscode install workspace 'My Prompt' # Install specific item to workspace")
290
+ .example('acp-vscode install my-package-id # Install package by id to workspace')
291
+ .example('')
292
+ .example('<target> can be:')
293
+ .example(' workspace Install to current workspace (.github folder) Default if not passed')
294
+ .example(' user Install to VS Code user profile')
295
+ .example(' <type> One of: prompts|chatmodes|agents|instructions|skills|all')
296
+ .example(' (installs all items of that type to workspace)')
297
+ .example(' <package-name> Package id/name (installs to workspace; supports repo:id or repo:type:id)')
298
+ .example('')
299
+ .example('[names...] optional item names to install (supports partial matching)')
300
+ .action(async (target, names, options) => {
301
+ // names may be undefined or an array. Support legacy positional type in case
302
+ // the user still passed it as the first name (e.g. `install workspace prompts p1`).
303
+ const TYPES = ['prompts','chatmodes','agents','instructions','skills','all'];
304
+ let type = options.type;
305
+ // Normalize names to an array. Some CLI parsers may provide a single
306
+ // name as a string instead of a one-element array. Preserve values.
307
+ let nm;
308
+ if (names === undefined || names === null) nm = [];
309
+ else if (Array.isArray(names)) nm = names.slice();
310
+ else nm = [names];
311
+ if (!type && nm.length > 0 && TYPES.includes(nm[0])) {
312
+ type = nm.shift();
313
+ }
314
+ await performInstall({ target, type, names: nm, options, workspaceDir: process.cwd() });
315
+ });
283
316
  }
284
317
 
285
318
  module.exports = { installCommand, performInstall };
@@ -51,7 +51,8 @@ function searchCommand(cli) {
51
51
  if (name.includes(q) || JSON.stringify(item).toLowerCase().includes(q)) {
52
52
  const rawId = item.id || item.name;
53
53
  const conflicts = (index && index._conflicts) ? new Set(index._conflicts) : new Set();
54
- const id = (conflicts.has(rawId) && item.repo) ? `${item.repo}:${rawId}` : rawId;
54
+ // Use hierarchical ID format (repo:type:id) when there's a conflict
55
+ const id = (conflicts.has(rawId) && item.repo) ? `${item.repo}:${cat.slice(0,-1)}:${rawId}` : rawId;
55
56
  const res = { type: cat.slice(0,-1), id, name: item.name };
56
57
  if (options && options.verbose) console.log('verbose: match', res);
57
58
  results.push(res);
@@ -80,7 +81,8 @@ function searchIndex(index, query) {
80
81
  if (name.includes(q) || JSON.stringify(item).toLowerCase().includes(q)) {
81
82
  const rawId = item.id || item.name;
82
83
  const conflicts = (index && index._conflicts) ? new Set(index._conflicts) : new Set();
83
- const id = (conflicts.has(rawId) && item.repo) ? `${item.repo}:${rawId}` : rawId;
84
+ // Use hierarchical ID format (repo:type:id) when there's a conflict
85
+ const id = (conflicts.has(rawId) && item.repo) ? `${item.repo}:${cat.slice(0,-1)}:${rawId}` : rawId;
84
86
  results.push({ type: cat.slice(0,-1), id, name: item.name });
85
87
  }
86
88
  });
package/src/fetcher.js CHANGED
@@ -242,7 +242,8 @@ async function fetchIndex(options) {
242
242
  log('repo ' + repo.id + ' response does not contain tree array, skipping', verbose);
243
243
  continue;
244
244
  }
245
- const tree = res.data.tree.filter(t => t.type === 'blob');
245
+ // Filter for blob entries, excluding symlinks (mode 120000) which would create duplicates
246
+ const tree = res.data.tree.filter(t => t.type === 'blob' && t.mode !== '120000');
246
247
  log('repo ' + repo.id + ' has ' + tree.length + ' blob items', verbose);
247
248
 
248
249
  const makeEntriesForRepo = async prefix => {
@@ -275,31 +276,116 @@ async function fetchIndex(options) {
275
276
  return parts;
276
277
  };
277
278
 
279
+ const makeSkillEntriesForRepo = async () => {
280
+ // Match SKILL.md files in skills folders at any depth: e.g., "skills/skill-name/SKILL.md"
281
+ const skillRegex = new RegExp(`(^|/)skills/[^/]+/SKILL\\.md$`, 'i');
282
+ const matches = tree.filter(t => skillRegex.test(t.path) && t.type === 'blob');
283
+ log('repo ' + repo.id + ': found ' + matches.length + ' skill folders (searched at any depth)', verbose);
284
+ const parts = await Promise.all(matches.map(async t => {
285
+ // Extract the skill folder name from path like "skills/skill-name/SKILL.md" or ".github/skills/skill-name/SKILL.md"
286
+ const pathParts = t.path.split('/');
287
+ const skillMdIndex = pathParts.findIndex(p => p.toLowerCase() === 'skill.md');
288
+ const skillFolderName = pathParts[skillMdIndex - 1];
289
+ const skillFolderPath = t.path.replace(/SKILL\.md$/i, '').replace(/\/$/, '');
290
+ const id = skillFolderName;
291
+ let name = skillFolderName.replace(/[-_]+/g, ' ').replace(/\b\w/g, l => l.toUpperCase());
292
+ let description = '';
293
+ const rawBase = (repo.rawBase || repo.url || '').replace(/\/$/, '');
294
+ const skillMdUrl = rawBase ? `${rawBase}/${t.path}` : null;
295
+
296
+ // Gather all files in the skill folder
297
+ const skillFiles = tree.filter(file => file.path.startsWith(skillFolderPath + '/') && file.type === 'blob');
298
+ const files = skillFiles.map(file => ({
299
+ path: file.path,
300
+ url: rawBase ? `${rawBase}/${file.path}` : null
301
+ }));
302
+
303
+ if (skillMdUrl) {
304
+ try {
305
+ const r = await axios.get(skillMdUrl, { timeout: 5000, headers: { 'User-Agent': 'acp-vscode-cli' } });
306
+ const content = r.data;
307
+ // Parse frontmatter to extract name and description
308
+ if (content.startsWith('---')) {
309
+ const endIndex = content.indexOf('\n---', 3);
310
+ if (endIndex !== -1) {
311
+ const frontmatter = content.substring(3, endIndex).trim();
312
+ const nameMatch = frontmatter.match(/^name:\s*(.+)$/m);
313
+ const descMatch = frontmatter.match(/^description:\s*(.+)$/m);
314
+ if (nameMatch) {
315
+ name = nameMatch[1].trim();
316
+ log('loaded name for skill ' + id + ': ' + name, verbose);
317
+ }
318
+ if (descMatch) {
319
+ description = descMatch[1].trim();
320
+ log('loaded description for skill ' + id, verbose);
321
+ }
322
+ }
323
+ }
324
+ } catch (e) {
325
+ log('failed to fetch SKILL.md for ' + id + ': ' + e.message, verbose);
326
+ // keep fallback name and empty description
327
+ }
328
+ }
329
+ log('collected ' + files.length + ' files for skill ' + id, verbose);
330
+ // Return skill entry with all files so installer can copy the entire folder structure
331
+ return { id, name, description, path: t.path, folderPath: skillFolderPath, url: skillMdUrl, repo: repo.id, isFolder: true, files };
332
+ }));
333
+ return parts;
334
+ };
335
+
278
336
  combined.prompts.push(...(await makeEntriesForRepo('prompts')));
279
337
  combined.chatmodes.push(...(await makeEntriesForRepo('chatmodes')));
280
338
  combined.agents.push(...(await makeEntriesForRepo('agents')));
281
339
  combined.instructions.push(...(await makeEntriesForRepo('instructions')));
282
- combined.skills.push(...(await makeEntriesForRepo('skills')));
340
+ combined.skills.push(...(await makeSkillEntriesForRepo()));
283
341
  }
284
342
 
285
- // detect id conflicts across repos
286
- log('detecting conflicts across repos...', verbose);
287
- const idCounts = new Map();
288
- for (const cat of ['prompts','chatmodes','agents','instructions','skills']) {
289
- for (const it of combined[cat]) {
290
- const k = it.id || it.name || '';
291
- if (!k) continue;
292
- idCounts.set(k, (idCounts.get(k) || 0) + 1);
343
+ // detect id conflicts at repo and type levels
344
+ log('detecting conflicts (repo-level and type-level)...', verbose);
345
+ const idRepoTypeMap = new Map(); // Maps "id" -> Map of "repo:type" -> count
346
+
347
+ for (const type of ['prompts','chatmodes','agents','instructions','skills']) {
348
+ for (const item of combined[type]) {
349
+ const id = item.id || item.name || '';
350
+ if (!id) continue;
351
+
352
+ const key = `${item.repo}:${type}`;
353
+ if (!idRepoTypeMap.has(id)) {
354
+ idRepoTypeMap.set(id, new Map());
355
+ }
356
+ idRepoTypeMap.get(id).set(key, (idRepoTypeMap.get(id).get(key) || 0) + 1);
293
357
  }
294
358
  }
295
- const conflicts = [];
296
- for (const [key, cnt] of idCounts.entries()) {
297
- if (cnt > 1) {
298
- conflicts.push(key);
299
- log('conflict detected: ' + key + ' appears ' + cnt + ' times', verbose);
359
+
360
+ // Determine which IDs need prefixing (repo-level or type-level conflicts)
361
+ const conflicts = new Set();
362
+ for (const [id, repoTypeMap] of idRepoTypeMap.entries()) {
363
+ // Group by type to check if same type appears in multiple repos
364
+ const typeToRepos = new Map();
365
+ for (const repoType of repoTypeMap.keys()) {
366
+ const [repo, type] = repoType.split(':');
367
+ if (!typeToRepos.has(type)) {
368
+ typeToRepos.set(type, new Set());
369
+ }
370
+ typeToRepos.get(type).add(repo);
371
+ }
372
+
373
+ // Check for repo-level conflict: same ID, same type, different repos
374
+ for (const [type, repos] of typeToRepos.entries()) {
375
+ if (repos.size > 1) {
376
+ conflicts.add(id);
377
+ log(`repo-level conflict: ${id} (${type}) in repos: ${Array.from(repos).join(', ')}`, verbose);
378
+ }
379
+ }
380
+
381
+ // Check for type-level conflict: same ID, multiple types
382
+ if (typeToRepos.size > 1) {
383
+ conflicts.add(id);
384
+ const types = Array.from(typeToRepos.keys()).join(', ');
385
+ log(`type-level conflict: ${id} appears in types: ${types}`, verbose);
300
386
  }
301
387
  }
302
- if (conflicts.length === 0) {
388
+ if (conflicts.size === 0) {
303
389
  log('no conflicts detected', verbose);
304
390
  }
305
391
 
@@ -314,7 +400,7 @@ async function fetchIndex(options) {
314
400
 
315
401
  idx = combined;
316
402
  idx._repos = repos.map(r => ({ id: r.id, treeUrl: r.treeUrl, rawBase: r.rawBase || r.url }));
317
- idx._conflicts = conflicts;
403
+ idx._conflicts = Array.from(conflicts); // Convert Set to array for JSON serialization
318
404
  cache.set(key, idx);
319
405
  await writeDiskCache(idx, verbose);
320
406
  log('index successfully built and cached', verbose);
package/src/installer.js CHANGED
@@ -1,6 +1,7 @@
1
1
  const fs = require('fs-extra');
2
2
  const path = require('path');
3
3
  const os = require('os');
4
+ const axios = require('axios');
4
5
 
5
6
  function getVsCodeUserDir() {
6
7
  const platform = os.platform();
@@ -24,6 +25,43 @@ function getVsCodeUserDir() {
24
25
  return path.join(home, '.config', 'Code', 'User');
25
26
  }
26
27
 
28
+ // Helper to sanitize folder names to prevent path traversal attacks
29
+ function makeSafeFolderName(rawName) {
30
+ // Ensure rawName is a string
31
+ if (typeof rawName !== 'string') {
32
+ rawName = rawName ? String(rawName) : '';
33
+ }
34
+ let safe = rawName || '';
35
+ // Replace any path separators with a dash so we don't create nested or absolute paths
36
+ safe = safe.replace(/[\\/]+/g, '-');
37
+ // Remove leading dots so values like "." or ".." don't become special path segments
38
+ safe = safe.replace(/^\.+/, '');
39
+ // Strip a leading Windows drive prefix like "C:\" or "D:/"
40
+ safe = safe.replace(/^[A-Za-z]:[-\\/]?/, '');
41
+ safe = safe.trim();
42
+ if (!safe) {
43
+ safe = 'skill';
44
+ }
45
+ return safe;
46
+ }
47
+
48
+ // Helper to get singular form of type for hierarchical ID matching
49
+ // Note: Assumes regular English plurals (e.g., prompts->prompt, skills->skill)
50
+ // which is appropriate for all current types: prompts, chatmodes, agents, instructions, skills
51
+ function getSingularType(type) {
52
+ if (!type || typeof type !== 'string') {
53
+ return type;
54
+ }
55
+ return type.endsWith('s') ? type.slice(0, -1) : type;
56
+ }
57
+
58
+ // Helper to check if a type segment matches the expected type (including singular form)
59
+ function isTypeMatch(typeSegment, expectedType) {
60
+ if (typeSegment === expectedType) return true;
61
+ const singularType = getSingularType(expectedType);
62
+ return typeSegment === singularType;
63
+ }
64
+
27
65
  async function installFiles({ items, type, target, workspaceDir }) {
28
66
  // type: prompts|chatmodes|agents|instructions|skills
29
67
  // Helper to derive filename and extension
@@ -39,7 +77,7 @@ async function installFiles({ items, type, target, workspaceDir }) {
39
77
  if (item.content) return item.content;
40
78
  if (item.url) {
41
79
  try {
42
- const r = await require('axios').get(item.url, { timeout: 10000, headers: { 'User-Agent': 'acp-vscode-cli' } });
80
+ const r = await axios.get(item.url, { timeout: 10000, headers: { 'User-Agent': 'acp-vscode-cli' } });
43
81
  return r.data;
44
82
  } catch (e) {
45
83
  return null;
@@ -48,6 +86,161 @@ async function installFiles({ items, type, target, workspaceDir }) {
48
86
  return null;
49
87
  };
50
88
 
89
+ const fetchFileContent = async fileInfo => {
90
+ if (!fileInfo.url) return null;
91
+ try {
92
+ const r = await axios.get(fileInfo.url, { timeout: 10000, headers: { 'User-Agent': 'acp-vscode-cli' } });
93
+ return r.data;
94
+ } catch (e) {
95
+ console.warn(`Failed to fetch ${fileInfo.path}: ${e.message}`);
96
+ return null;
97
+ }
98
+ };
99
+
100
+ // Special handling for skills: they are folders with SKILL.md inside and supporting files
101
+ if (type === 'skills') {
102
+ if (target === 'workspace') {
103
+ const base = path.join(workspaceDir, '.github', 'skills');
104
+ await fs.ensureDir(base);
105
+ // detect duplicate ids so we can disambiguate folder names by prefixing
106
+ const idCounts = items.reduce((m, it) => { const k = it.id || it.name || ''; m[k] = (m[k] || 0) + 1; return m; }, {});
107
+ for (const item of items) {
108
+ const baseName = item.id || item.name || `skill-${Date.now()}`;
109
+ let folderName = baseName;
110
+ if (idCounts[baseName] > 1 && item.repo) {
111
+ // prefix with repo to avoid overwriting folders when multiple repos have the same id
112
+ folderName = `${item.repo}-${baseName}`;
113
+ }
114
+ folderName = makeSafeFolderName(folderName);
115
+ const skillFolderPath = path.join(base, folderName);
116
+ await fs.ensureDir(skillFolderPath);
117
+
118
+ // If item has files array (from fetcher), copy all files
119
+ if (item.files && Array.isArray(item.files) && item.files.length > 0) {
120
+ const folderPathPrefix = item.folderPath || '';
121
+ for (const fileInfo of item.files) {
122
+ // Derive a relative path without using an unsafe RegExp constructed from folderPathPrefix
123
+ let relativePath = fileInfo.path;
124
+ if (folderPathPrefix && relativePath.startsWith(`${folderPathPrefix}/`)) {
125
+ relativePath = relativePath.slice(folderPathPrefix.length + 1);
126
+ }
127
+
128
+ // Normalize and sanitize the relative path to prevent directory traversal or absolute paths
129
+ let safeRelativePath = path.normalize(relativePath);
130
+ // Remove any leading path separators so the path remains relative
131
+ while (safeRelativePath.startsWith(path.sep) || safeRelativePath.startsWith('/')) {
132
+ safeRelativePath = safeRelativePath.slice(1);
133
+ }
134
+ // Check if the path is absolute (including Windows paths like C:\...)
135
+ if (path.isAbsolute(safeRelativePath)) {
136
+ console.warn(`Skipping absolute path: ${fileInfo.path}`);
137
+ continue;
138
+ }
139
+ // After normalization, disallow any attempts to escape the skill folder
140
+ if (
141
+ safeRelativePath === '..' ||
142
+ safeRelativePath.startsWith(`..${path.sep}`) ||
143
+ safeRelativePath.includes(`${path.sep}..${path.sep}`) ||
144
+ safeRelativePath.endsWith(`${path.sep}..`)
145
+ ) {
146
+ console.warn(`Skipping potentially unsafe path outside skill folder: ${fileInfo.path}`);
147
+ continue;
148
+ }
149
+
150
+ const filePath = path.join(skillFolderPath, safeRelativePath);
151
+
152
+ // Create directory if needed
153
+ await fs.ensureDir(path.dirname(filePath));
154
+
155
+ // Fetch and write file
156
+ const fileContent = await fetchFileContent(fileInfo);
157
+
158
+ if (fileContent !== null && fileContent !== undefined) {
159
+ const contentStr = typeof fileContent !== 'string' ? JSON.stringify(fileContent, null, 2) : fileContent;
160
+ await fs.writeFile(filePath, contentStr, 'utf8');
161
+ }
162
+ }
163
+ } else {
164
+ // Fallback: if no files array, just write SKILL.md (for backward compatibility)
165
+ const skillMdPath = path.join(skillFolderPath, 'SKILL.md');
166
+ let content = await fetchRawIfNeeded(item) || item.content || JSON.stringify(item, null, 2);
167
+ if (typeof content !== 'string') content = JSON.stringify(content, null, 2);
168
+ await fs.writeFile(skillMdPath, content, 'utf8');
169
+ }
170
+ }
171
+ return base;
172
+ }
173
+
174
+ // user target: install skills to ~/.copilot/skills/
175
+ const home = process.env.HOME || os.homedir();
176
+ const base = path.join(home, '.copilot', 'skills');
177
+ await fs.ensureDir(base);
178
+ // detect duplicates among items to avoid overwriting
179
+ const idCounts = items.reduce((m, it) => { const k = it.id || it.name || ''; m[k] = (m[k] || 0) + 1; return m; }, {});
180
+ for (const item of items) {
181
+ const baseName = item.id || item.name || `skill-${Date.now()}`;
182
+ let folderName = baseName;
183
+ if (idCounts[baseName] > 1 && item.repo) folderName = `${item.repo}-${baseName}`;
184
+ folderName = makeSafeFolderName(folderName);
185
+ const skillFolderPath = path.join(base, folderName);
186
+ await fs.ensureDir(skillFolderPath);
187
+
188
+ if (item.files && Array.isArray(item.files) && item.files.length > 0) {
189
+ const folderPathPrefix = item.folderPath || '';
190
+ for (const fileInfo of item.files) {
191
+ // Derive a relative path without using an unsafe RegExp constructed from folderPathPrefix
192
+ let relativePath = fileInfo.path;
193
+ if (folderPathPrefix && relativePath.startsWith(`${folderPathPrefix}/`)) {
194
+ relativePath = relativePath.slice(folderPathPrefix.length + 1);
195
+ }
196
+
197
+ // Normalize and sanitize the relative path to prevent directory traversal or absolute paths
198
+ let safeRelativePath = path.normalize(relativePath);
199
+ // Remove any leading path separators so the path remains relative
200
+ while (safeRelativePath.startsWith(path.sep) || safeRelativePath.startsWith('/')) {
201
+ safeRelativePath = safeRelativePath.slice(1);
202
+ }
203
+ // Check if the path is absolute (including Windows paths like C:\...)
204
+ if (path.isAbsolute(safeRelativePath)) {
205
+ console.warn(`Skipping absolute path: ${fileInfo.path}`);
206
+ continue;
207
+ }
208
+ // After normalization, disallow any attempts to escape the skill folder
209
+ if (
210
+ safeRelativePath === '..' ||
211
+ safeRelativePath.startsWith(`..${path.sep}`) ||
212
+ safeRelativePath.includes(`${path.sep}..${path.sep}`) ||
213
+ safeRelativePath.endsWith(`${path.sep}..`)
214
+ ) {
215
+ console.warn(`Skipping potentially unsafe path outside skill folder: ${fileInfo.path}`);
216
+ continue;
217
+ }
218
+
219
+ const filePath = path.join(skillFolderPath, safeRelativePath);
220
+
221
+ // Create directory if needed
222
+ await fs.ensureDir(path.dirname(filePath));
223
+
224
+ // Fetch and write file
225
+ const fileContent = await fetchFileContent(fileInfo);
226
+
227
+ if (fileContent !== null && fileContent !== undefined) {
228
+ const contentStr = typeof fileContent !== 'string' ? JSON.stringify(fileContent, null, 2) : fileContent;
229
+ await fs.writeFile(filePath, contentStr, 'utf8');
230
+ }
231
+ }
232
+ } else {
233
+ // Fallback: if no files array, just write SKILL.md (for backward compatibility)
234
+ const skillMdPath = path.join(skillFolderPath, 'SKILL.md');
235
+ let content = await fetchRawIfNeeded(item) || item.content || JSON.stringify(item, null, 2);
236
+ if (typeof content !== 'string') content = JSON.stringify(content, null, 2);
237
+ await fs.writeFile(skillMdPath, content, 'utf8');
238
+ }
239
+ }
240
+ return base;
241
+ }
242
+
243
+ // Standard handling for other types (prompts, agents, chatmodes, instructions)
51
244
  if (target === 'workspace') {
52
245
  const base = path.join(workspaceDir, '.github', type);
53
246
  await fs.ensureDir(base);
@@ -68,7 +261,7 @@ async function installFiles({ items, type, target, workspaceDir }) {
68
261
  return base;
69
262
  }
70
263
 
71
- // user target: write all types into the VS Code User 'prompts' folder so user profile
264
+ // user target: write all types (except skills) into the VS Code User 'prompts' folder so user profile
72
265
  // keeps everything together (per user's requested behavior).
73
266
  const userDir = getVsCodeUserDir();
74
267
  const base = path.join(userDir, 'prompts');
@@ -89,6 +282,78 @@ async function installFiles({ items, type, target, workspaceDir }) {
89
282
 
90
283
  async function removeFiles({ names, type, target, workspaceDir }) {
91
284
  // remove files by id or name from the target
285
+ // Special handling for skills: they are folders, not files
286
+ if (type === 'skills') {
287
+ if (target === 'workspace') {
288
+ const base = path.join(workspaceDir, '.github', 'skills');
289
+ if (!(await fs.pathExists(base))) return 0;
290
+ const dirs = await fs.readdir(base);
291
+ let removed = 0;
292
+ for (const d of dirs) {
293
+ const p = path.join(base, d);
294
+ const stats = await fs.stat(p).catch(() => null);
295
+ if (!stats || !stats.isDirectory()) continue; // skip non-directories
296
+ const matches = names.some(n => {
297
+ if (typeof n !== 'string') return false;
298
+ if (n.includes(':')) {
299
+ // repo-qualified incoming name like "repo:type:skill-id" or "repo:skill-id" (legacy)
300
+ const parts = n.split(':');
301
+ if (parts.length === 3) {
302
+ const [repo, typePart, id] = parts;
303
+ // For skills, type must match 'skills' or its singular form 'skill'
304
+ if (!isTypeMatch(typePart, 'skills')) return false;
305
+ return d === id || d === `${repo}-${id}`;
306
+ } else if (parts.length === 2) {
307
+ const [repo, id] = parts;
308
+ // Check if folder name matches either "skill-id" or "repo-skill-id" pattern
309
+ return d === id || d === `${repo}-${id}`;
310
+ }
311
+ }
312
+ return n === d; // direct match on folder name
313
+ });
314
+ if (matches) {
315
+ await fs.remove(p);
316
+ removed++;
317
+ }
318
+ }
319
+ return removed;
320
+ }
321
+
322
+ // user target: skills are in ~/.copilot/skills/
323
+ const home = process.env.HOME || os.homedir();
324
+ const base = path.join(home, '.copilot', 'skills');
325
+ if (!(await fs.pathExists(base))) return 0;
326
+ const dirs = await fs.readdir(base);
327
+ let removed = 0;
328
+ for (const d of dirs) {
329
+ const p = path.join(base, d);
330
+ const stats = await fs.stat(p).catch(() => null);
331
+ if (!stats || !stats.isDirectory()) continue; // skip non-directories
332
+ const matches = names.some(n => {
333
+ if (typeof n !== 'string') return false;
334
+ if (n.includes(':')) {
335
+ const parts = n.split(':');
336
+ if (parts.length === 3) {
337
+ const [repo, typePart, id] = parts;
338
+ // For skills, type must match 'skills' or its singular form 'skill'
339
+ if (!isTypeMatch(typePart, 'skills')) return false;
340
+ return d === id || d === `${repo}-${id}`;
341
+ } else if (parts.length === 2) {
342
+ const [repo, id] = parts;
343
+ return d === id || d === `${repo}-${id}`;
344
+ }
345
+ }
346
+ return n === d;
347
+ });
348
+ if (matches) {
349
+ await fs.remove(p);
350
+ removed++;
351
+ }
352
+ }
353
+ return removed;
354
+ }
355
+
356
+ // Standard handling for other types (files)
92
357
  if (target === 'workspace') {
93
358
  const base = path.join(workspaceDir, '.github', type);
94
359
  if (!(await fs.pathExists(base))) return 0;
@@ -97,14 +362,33 @@ async function removeFiles({ names, type, target, workspaceDir }) {
97
362
  for (const f of files) {
98
363
  const p = path.join(base, f);
99
364
  const content = await fs.readJson(p).catch(() => null);
100
- // allow incoming name formats: 'repo:id' or 'id'
365
+ // allow incoming name formats: 'repo:type:id', 'repo:id' (legacy), or 'id'
101
366
  const fileId = content && (content.id || content.name) ? (content.id || content.name) : f;
102
- const strippedFileId = (typeof fileId === 'string' && fileId.includes(':')) ? fileId.split(':')[1] : fileId;
367
+ // Extract the raw ID from hierarchical format
368
+ const parseHierarchicalId = (id) => {
369
+ if (typeof id !== 'string') return id;
370
+ const parts = id.split(':');
371
+ if (parts.length === 3) return parts[2]; // repo:type:id -> id
372
+ if (parts.length === 2) return parts[1]; // repo:id -> id
373
+ return id;
374
+ };
375
+ const strippedFileId = parseHierarchicalId(fileId);
103
376
  const matches = names.some(n => {
104
377
  if (typeof n !== 'string') return false;
105
378
  if (n.includes(':')) {
106
- // repo-qualified incoming name
107
- return n === fileId || n === `${content && content.repo ? content.repo : ''}:${strippedFileId}`;
379
+ const parts = n.split(':');
380
+ if (parts.length === 3) {
381
+ const [repo, typeSegment, id] = parts;
382
+ // only match repo:type:id when the type segment matches the current type (or its singular form)
383
+ if (!isTypeMatch(typeSegment, type)) {
384
+ return false;
385
+ }
386
+ return n === fileId || (content && content.repo === repo && strippedFileId === id);
387
+ } else if (parts.length === 2) {
388
+ // Legacy format: repo:id
389
+ const [repo, id] = parts;
390
+ return n === fileId || (content && content.repo === repo && (strippedFileId === id));
391
+ }
108
392
  }
109
393
  return n === fileId || n === strippedFileId || n === f;
110
394
  });
@@ -126,11 +410,29 @@ async function removeFiles({ names, type, target, workspaceDir }) {
126
410
  const p = path.join(base, f);
127
411
  const content = await fs.readJson(p).catch(() => null);
128
412
  const fileId = content && (content.id || content.name) ? (content.id || content.name) : f;
129
- const strippedFileId = (typeof fileId === 'string' && fileId.includes(':')) ? fileId.split(':')[1] : fileId;
413
+ const parseHierarchicalId = (id) => {
414
+ if (typeof id !== 'string') return id;
415
+ const parts = id.split(':');
416
+ if (parts.length === 3) return parts[2]; // repo:type:id -> id
417
+ if (parts.length === 2) return parts[1]; // repo:id -> id
418
+ return id;
419
+ };
420
+ const strippedFileId = parseHierarchicalId(fileId);
130
421
  const matches = names.some(n => {
131
422
  if (typeof n !== 'string') return false;
132
423
  if (n.includes(':')) {
133
- return n === fileId || n === `${content && content.repo ? content.repo : ''}:${strippedFileId}`;
424
+ const parts = n.split(':');
425
+ if (parts.length === 3) {
426
+ const [repo, typeSegment, id] = parts;
427
+ // only match repo:type:id when the type segment matches the current type (or its singular form)
428
+ if (!isTypeMatch(typeSegment, type)) {
429
+ return false;
430
+ }
431
+ return n === fileId || (content && content.repo === repo && (strippedFileId === id));
432
+ } else if (parts.length === 2) {
433
+ const [repo, id] = parts;
434
+ return n === fileId || (content && content.repo === repo && (strippedFileId === id));
435
+ }
134
436
  }
135
437
  return n === fileId || n === strippedFileId || n === f;
136
438
  });