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.
- package/__tests__/commands-actions.test.js +1 -0
- package/__tests__/installer-skills.test.js +266 -0
- package/__tests__/search-index.test.js +1 -1
- package/package.json +1 -1
- package/src/commands/install.js +57 -24
- package/src/commands/search.js +4 -2
- package/src/fetcher.js +103 -17
- package/src/installer.js +310 -8
|
@@ -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
package/src/commands/install.js
CHANGED
|
@@ -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: <
|
|
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 (
|
|
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 (
|
|
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 (
|
|
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 (
|
|
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
|
|
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
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
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 };
|
package/src/commands/search.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
340
|
+
combined.skills.push(...(await makeSkillEntriesForRepo()));
|
|
283
341
|
}
|
|
284
342
|
|
|
285
|
-
// detect id conflicts
|
|
286
|
-
log('detecting conflicts
|
|
287
|
-
const
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
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
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
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.
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
107
|
-
|
|
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
|
|
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
|
-
|
|
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
|
});
|