agentinit 1.19.0 → 1.20.1

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.
Files changed (48) hide show
  1. package/CHANGELOG.md +25 -0
  2. package/README.md +46 -4
  3. package/dist/cli.js +1927 -509
  4. package/dist/cli.js.map +1 -1
  5. package/dist/commands/lock.d.ts +3 -0
  6. package/dist/commands/lock.d.ts.map +1 -0
  7. package/dist/commands/lock.js +160 -0
  8. package/dist/commands/lock.js.map +1 -0
  9. package/dist/commands/mcp.d.ts.map +1 -1
  10. package/dist/commands/mcp.js +60 -0
  11. package/dist/commands/mcp.js.map +1 -1
  12. package/dist/commands/rules.d.ts.map +1 -1
  13. package/dist/commands/rules.js +50 -0
  14. package/dist/commands/rules.js.map +1 -1
  15. package/dist/commands/skills.d.ts.map +1 -1
  16. package/dist/commands/skills.js +299 -20
  17. package/dist/commands/skills.js.map +1 -1
  18. package/dist/core/installLock.d.ts +54 -0
  19. package/dist/core/installLock.d.ts.map +1 -0
  20. package/dist/core/installLock.js +274 -0
  21. package/dist/core/installLock.js.map +1 -0
  22. package/dist/core/skillSecurityScanner.d.ts +28 -0
  23. package/dist/core/skillSecurityScanner.d.ts.map +1 -0
  24. package/dist/core/skillSecurityScanner.js +167 -0
  25. package/dist/core/skillSecurityScanner.js.map +1 -0
  26. package/dist/core/skillsManager.d.ts +12 -0
  27. package/dist/core/skillsManager.d.ts.map +1 -1
  28. package/dist/core/skillsManager.js +373 -14
  29. package/dist/core/skillsManager.js.map +1 -1
  30. package/dist/types/index.d.ts +1 -0
  31. package/dist/types/index.d.ts.map +1 -1
  32. package/dist/types/lockfile.d.ts +68 -0
  33. package/dist/types/lockfile.d.ts.map +1 -0
  34. package/dist/types/lockfile.js +2 -0
  35. package/dist/types/lockfile.js.map +1 -0
  36. package/dist/types/plugins.d.ts +1 -1
  37. package/dist/types/plugins.d.ts.map +1 -1
  38. package/dist/types/skills.d.ts +4 -1
  39. package/dist/types/skills.d.ts.map +1 -1
  40. package/dist/utils/lockSource.d.ts +9 -0
  41. package/dist/utils/lockSource.d.ts.map +1 -0
  42. package/dist/utils/lockSource.js +59 -0
  43. package/dist/utils/lockSource.js.map +1 -0
  44. package/dist/utils/promptUtils.d.ts +13 -2
  45. package/dist/utils/promptUtils.d.ts.map +1 -1
  46. package/dist/utils/promptUtils.js +61 -3
  47. package/dist/utils/promptUtils.js.map +1 -1
  48. package/package.json +1 -1
@@ -6,9 +6,12 @@ import { createHash } from 'crypto';
6
6
  import { promisify } from 'util';
7
7
  import matter from 'gray-matter';
8
8
  import { createRelativeSymlink, fileExists, isDirectory, listFiles, readFileIfExists, resolveRealPathOrSelf, } from '../utils/fs.js';
9
+ import { expandTilde } from '../utils/paths.js';
9
10
  import { AgentManager } from './agentManager.js';
10
11
  import { getConfiguredDefaultMarketplaceId, getMarketplace, getMarketplaceIds } from './marketplaceRegistry.js';
12
+ import { SkillSecurityScanner } from './skillSecurityScanner.js';
11
13
  import { SHARED_SKILLS_TARGET_ID } from '../types/skills.js';
14
+ import { InstallLock, hashDirectory, logLockWriteWarning } from './installLock.js';
12
15
  const execFileAsync = promisify(execFile);
13
16
  const DEFAULT_SKILLS_CATALOG = {
14
17
  owner: 'vercel-labs',
@@ -31,6 +34,7 @@ const SKILL_SEARCH_DIRS = [
31
34
  export class SkillsManager {
32
35
  agentManager;
33
36
  preparedSourceContexts = new Map();
37
+ skillScanner = new SkillSecurityScanner();
34
38
  constructor(agentManager) {
35
39
  this.agentManager = agentManager || new AgentManager();
36
40
  }
@@ -43,12 +47,16 @@ export class SkillsManager {
43
47
  return { type: 'local', path: source };
44
48
  }
45
49
  // Full GitHub URL
46
- const githubUrlSource = this.parseGitHubHttpSource(source);
47
- if (githubUrlSource) {
48
- return githubUrlSource;
50
+ const httpSource = this.parseHttpRepositorySource(source);
51
+ if (httpSource) {
52
+ return httpSource;
49
53
  }
50
54
  // Full git URL
51
- if (source.startsWith('git@') || source.endsWith('.git')) {
55
+ const sshSource = this.parseSshRepositorySource(source);
56
+ if (sshSource) {
57
+ return sshSource;
58
+ }
59
+ if (source.endsWith('.git')) {
52
60
  return { type: 'github', url: source };
53
61
  }
54
62
  if (options?.from) {
@@ -61,6 +69,14 @@ export class SkillsManager {
61
69
  pluginName: source,
62
70
  };
63
71
  }
72
+ const gitLabShorthandSource = this.parseGitLabShorthandSource(source);
73
+ if (gitLabShorthandSource) {
74
+ return gitLabShorthandSource;
75
+ }
76
+ const bitbucketShorthandSource = this.parseBitbucketShorthandSource(source);
77
+ if (bitbucketShorthandSource) {
78
+ return bitbucketShorthandSource;
79
+ }
64
80
  const githubShorthandSource = this.parseGitHubShorthandSource(source);
65
81
  if (githubShorthandSource?.subpath) {
66
82
  return githubShorthandSource;
@@ -161,6 +177,110 @@ export class SkillsManager {
161
177
  return null;
162
178
  }
163
179
  }
180
+ parseGitLabHttpSource(source) {
181
+ if (!source.startsWith('https://gitlab.com/') && !source.startsWith('http://gitlab.com/')) {
182
+ return null;
183
+ }
184
+ try {
185
+ const parsedUrl = new URL(source);
186
+ const segments = parsedUrl.pathname.replace(/\/+$/, '').split('/').filter(Boolean);
187
+ const dashIndex = segments.indexOf('-');
188
+ const repoBoundary = dashIndex >= 0 ? dashIndex : segments.length;
189
+ if (repoBoundary < 2) {
190
+ return null;
191
+ }
192
+ const repo = segments[repoBoundary - 1];
193
+ const owner = segments.slice(0, repoBoundary - 1).join('/');
194
+ if (!owner || !repo) {
195
+ return null;
196
+ }
197
+ let subpath;
198
+ if (dashIndex >= 0) {
199
+ const marker = segments[dashIndex + 1];
200
+ if ((marker === 'tree' || marker === 'blob') && segments.length > dashIndex + 3) {
201
+ subpath = segments.slice(dashIndex + 3).join('/');
202
+ }
203
+ }
204
+ return {
205
+ type: 'gitlab',
206
+ url: `https://gitlab.com/${owner}/${repo}.git`,
207
+ owner,
208
+ repo,
209
+ ...(subpath ? { subpath } : {}),
210
+ };
211
+ }
212
+ catch {
213
+ return null;
214
+ }
215
+ }
216
+ parseBitbucketHttpSource(source) {
217
+ if (!source.startsWith('https://bitbucket.org/') && !source.startsWith('http://bitbucket.org/')) {
218
+ return null;
219
+ }
220
+ try {
221
+ const parsedUrl = new URL(source);
222
+ const segments = parsedUrl.pathname.replace(/\/+$/, '').split('/').filter(Boolean);
223
+ if (segments.length < 2) {
224
+ return null;
225
+ }
226
+ const [owner, repo, marker, _commitish, ...rest] = segments;
227
+ if (!owner || !repo) {
228
+ return null;
229
+ }
230
+ let subpath;
231
+ if (marker === 'src' && rest.length > 0) {
232
+ subpath = rest.join('/');
233
+ }
234
+ return {
235
+ type: 'bitbucket',
236
+ url: `https://bitbucket.org/${owner}/${repo}.git`,
237
+ owner,
238
+ repo,
239
+ ...(subpath ? { subpath } : {}),
240
+ };
241
+ }
242
+ catch {
243
+ return null;
244
+ }
245
+ }
246
+ parseHttpRepositorySource(source) {
247
+ return this.parseGitHubHttpSource(source)
248
+ || this.parseGitLabHttpSource(source)
249
+ || this.parseBitbucketHttpSource(source);
250
+ }
251
+ parseSshRepositorySource(source) {
252
+ const githubMatch = source.match(/^git@github\.com:([^/]+)\/([^/]+?)(?:\.git)?$/);
253
+ if (githubMatch) {
254
+ const [, owner, repo] = githubMatch;
255
+ return {
256
+ type: 'github',
257
+ url: `git@github.com:${owner}/${repo}.git`,
258
+ owner,
259
+ repo,
260
+ };
261
+ }
262
+ const gitlabMatch = source.match(/^git@gitlab\.com:(.+)\/([^/]+?)(?:\.git)?$/);
263
+ if (gitlabMatch) {
264
+ const [, owner, repo] = gitlabMatch;
265
+ return {
266
+ type: 'gitlab',
267
+ url: `git@gitlab.com:${owner}/${repo}.git`,
268
+ owner,
269
+ repo,
270
+ };
271
+ }
272
+ const bitbucketMatch = source.match(/^git@bitbucket\.org:([^/]+)\/([^/]+?)(?:\.git)?$/);
273
+ if (bitbucketMatch) {
274
+ const [, owner, repo] = bitbucketMatch;
275
+ return {
276
+ type: 'bitbucket',
277
+ url: `git@bitbucket.org:${owner}/${repo}.git`,
278
+ owner,
279
+ repo,
280
+ };
281
+ }
282
+ return null;
283
+ }
164
284
  parseGitHubShorthandSource(source) {
165
285
  const githubShorthandMatch = source.match(/^([a-zA-Z0-9._-]+)\/([a-zA-Z0-9._-]+)(?:\/(.+))?$/);
166
286
  if (!githubShorthandMatch) {
@@ -175,6 +295,58 @@ export class SkillsManager {
175
295
  ...(subpath ? { subpath } : {}),
176
296
  };
177
297
  }
298
+ parseGitLabShorthandSource(source) {
299
+ const normalized = source.startsWith('gitlab:')
300
+ ? source.slice('gitlab:'.length)
301
+ : source.startsWith('gitlab.com/')
302
+ ? source.slice('gitlab.com/'.length)
303
+ : null;
304
+ if (!normalized) {
305
+ return null;
306
+ }
307
+ const [repoSpec = normalized, subpathSpec] = normalized.split('//', 2);
308
+ const segments = repoSpec.split('/').filter(Boolean);
309
+ if (segments.length < 2) {
310
+ return null;
311
+ }
312
+ const repo = segments[segments.length - 1];
313
+ const owner = segments.slice(0, segments.length - 1).join('/');
314
+ if (!owner || !repo) {
315
+ return null;
316
+ }
317
+ return {
318
+ type: 'gitlab',
319
+ url: `https://gitlab.com/${owner}/${repo}.git`,
320
+ owner,
321
+ repo,
322
+ ...(subpathSpec ? { subpath: subpathSpec } : {}),
323
+ };
324
+ }
325
+ parseBitbucketShorthandSource(source) {
326
+ const normalized = source.startsWith('bitbucket:')
327
+ ? source.slice('bitbucket:'.length)
328
+ : source.startsWith('bitbucket.org/')
329
+ ? source.slice('bitbucket.org/'.length)
330
+ : null;
331
+ if (!normalized) {
332
+ return null;
333
+ }
334
+ const segments = normalized.split('/').filter(Boolean);
335
+ if (segments.length < 2) {
336
+ return null;
337
+ }
338
+ const [owner, repo, ...rest] = segments;
339
+ if (!owner || !repo) {
340
+ return null;
341
+ }
342
+ return {
343
+ type: 'bitbucket',
344
+ url: `https://bitbucket.org/${owner}/${repo}.git`,
345
+ owner,
346
+ repo,
347
+ ...(rest.length > 0 ? { subpath: rest.join('/') } : {}),
348
+ };
349
+ }
178
350
  /**
179
351
  * Parse a SKILL.md file and extract name + description from frontmatter
180
352
  */
@@ -322,7 +494,7 @@ export class SkillsManager {
322
494
  }
323
495
  async resolveDiscoveryRoot(repoPath, source, sourceLabel) {
324
496
  const resolvedRepoPath = resolve(repoPath);
325
- if (source.type !== 'github' || !source.subpath) {
497
+ if ((source.type !== 'github' && source.type !== 'gitlab' && source.type !== 'bitbucket') || !source.subpath) {
326
498
  return resolvedRepoPath;
327
499
  }
328
500
  const discoveryRoot = resolve(resolvedRepoPath, source.subpath);
@@ -367,7 +539,7 @@ export class SkillsManager {
367
539
  };
368
540
  }
369
541
  let repoPath;
370
- if (resolved.type === 'github') {
542
+ if (resolved.type === 'github' || resolved.type === 'gitlab' || resolved.type === 'bitbucket') {
371
543
  if (!resolved.url) {
372
544
  throw new Error(`Invalid source: ${source}`);
373
545
  }
@@ -543,7 +715,7 @@ export class SkillsManager {
543
715
  ...(options.global !== undefined ? { global: options.global } : {}),
544
716
  ...(options.copy !== undefined ? { copy: options.copy } : {}),
545
717
  });
546
- return this.compareSkillSnapshot(skill, plan.canonicalPath || plan.path);
718
+ return this.compareSkillInstallStatus(skill, plan);
547
719
  }
548
720
  async installSkillForAgent(skillPath, skillName, agent, projectPath, options = {}) {
549
721
  const plan = await this.getInstallPlan(skillName, agent, projectPath, options);
@@ -625,6 +797,76 @@ export class SkillsManager {
625
797
  mode: 'symlink',
626
798
  };
627
799
  }
800
+ normalizeSkillPrefix(prefix) {
801
+ const normalized = prefix?.trim() ?? '';
802
+ if (normalized.includes('/') || normalized.includes('\\')) {
803
+ throw new Error(`Invalid skill prefix: ${prefix}`);
804
+ }
805
+ return normalized;
806
+ }
807
+ withSkillPrefix(skillName, prefix) {
808
+ const normalizedPrefix = this.normalizeSkillPrefix(prefix);
809
+ return normalizedPrefix ? `${normalizedPrefix}${skillName}` : skillName;
810
+ }
811
+ async rewriteSkillFileName(filePath, skillName) {
812
+ const content = await fs.readFile(filePath, 'utf8');
813
+ const parsed = matter(content);
814
+ const nextContent = matter.stringify(parsed.content, {
815
+ ...parsed.data,
816
+ name: skillName,
817
+ });
818
+ await fs.writeFile(filePath, nextContent, 'utf8');
819
+ }
820
+ async applyPrefixToSkills(skills, prefix) {
821
+ const normalizedPrefix = this.normalizeSkillPrefix(prefix);
822
+ if (!normalizedPrefix) {
823
+ return { skills, cleanup: async () => { } };
824
+ }
825
+ const tempDirs = [];
826
+ try {
827
+ const prefixedSkills = await Promise.all(skills.map(async (skill) => {
828
+ const name = this.withSkillPrefix(skill.name, normalizedPrefix);
829
+ if (skill.generatedContent) {
830
+ return {
831
+ ...skill,
832
+ name,
833
+ generatedContent: matter.stringify(matter(skill.generatedContent).content, {
834
+ ...matter(skill.generatedContent).data,
835
+ name,
836
+ }),
837
+ };
838
+ }
839
+ const tempRoot = await fs.mkdtemp(join(tmpdir(), 'agentinit-prefixed-skill-'));
840
+ const tempSkillPath = join(tempRoot, basename(skill.path));
841
+ tempDirs.push(tempRoot);
842
+ await this.copyDir(skill.path, tempSkillPath);
843
+ const skillMdPath = join(tempSkillPath, 'SKILL.md');
844
+ const skillMdPathLower = join(tempSkillPath, 'skill.md');
845
+ const skillFile = (await fileExists(skillMdPath)) ? skillMdPath
846
+ : (await fileExists(skillMdPathLower)) ? skillMdPathLower
847
+ : null;
848
+ if (!skillFile) {
849
+ throw new Error(`Skill "${skill.name}" is missing SKILL.md`);
850
+ }
851
+ await this.rewriteSkillFileName(skillFile, name);
852
+ return {
853
+ ...skill,
854
+ name,
855
+ path: tempSkillPath,
856
+ };
857
+ }));
858
+ return {
859
+ skills: prefixedSkills,
860
+ cleanup: async () => {
861
+ await Promise.all(tempDirs.map(dir => fs.rm(dir, { recursive: true, force: true }).catch(() => { })));
862
+ },
863
+ };
864
+ }
865
+ catch (error) {
866
+ await Promise.all(tempDirs.map(dir => fs.rm(dir, { recursive: true, force: true }).catch(() => { })));
867
+ throw error;
868
+ }
869
+ }
628
870
  normalizeSkillName(skillName) {
629
871
  const normalized = skillName.trim();
630
872
  if (!normalized) {
@@ -693,6 +935,23 @@ export class SkillsManager {
693
935
  return 'new';
694
936
  return existing === incoming ? 'unchanged' : 'changed';
695
937
  }
938
+ async compareSkillInstallStatus(skill, plan) {
939
+ const incoming = await this.getNewSkillSnapshot(skill);
940
+ if (incoming === null)
941
+ return 'new';
942
+ const existingAtTarget = await this.readExistingSkillSnapshot(plan.path);
943
+ if (existingAtTarget !== null) {
944
+ return existingAtTarget === incoming ? 'unchanged' : 'changed';
945
+ }
946
+ if (!plan.canonicalPath || plan.canonicalPath === plan.path) {
947
+ return 'new';
948
+ }
949
+ const existingAtCanonical = await this.readExistingSkillSnapshot(plan.canonicalPath);
950
+ if (existingAtCanonical === null) {
951
+ return 'new';
952
+ }
953
+ return existingAtCanonical === incoming ? 'new' : 'changed';
954
+ }
696
955
  async cleanAndCreateDirectory(path) {
697
956
  await fs.rm(path, { recursive: true, force: true }).catch(() => { });
698
957
  await fs.mkdir(path, { recursive: true });
@@ -713,11 +972,13 @@ export class SkillsManager {
713
972
  * Add skills from a source (GitHub repo or local path)
714
973
  */
715
974
  async addFromSource(source, projectPath, options = {}) {
975
+ const normalizedPrefix = this.normalizeSkillPrefix(options.prefix);
716
976
  const context = this.takePreparedSourceContext(source, projectPath, options.from)
717
977
  || await this.loadDiscoveredSkillsContext(source, projectPath, {
718
978
  ...(options.from !== undefined ? { from: options.from } : {}),
719
979
  ...(options.pluginName !== undefined ? { pluginName: options.pluginName } : {}),
720
980
  });
981
+ let prefixedCleanup = async () => { };
721
982
  try {
722
983
  let skills = context.skills;
723
984
  if (skills.length === 0) {
@@ -725,7 +986,37 @@ export class SkillsManager {
725
986
  }
726
987
  if (options.skills && options.skills.length > 0) {
727
988
  const names = new Set(options.skills.map(skill => skill.toLowerCase()));
728
- skills = skills.filter(skill => names.has(skill.name.toLowerCase()));
989
+ skills = skills.filter(skill => names.has(skill.name.toLowerCase())
990
+ || names.has(this.withSkillPrefix(skill.name, normalizedPrefix).toLowerCase()));
991
+ }
992
+ const prefixed = await this.applyPrefixToSkills(skills, normalizedPrefix);
993
+ skills = prefixed.skills;
994
+ prefixedCleanup = prefixed.cleanup;
995
+ const result = { installed: [], updated: [], unchanged: [], skipped: [], warnings: [...context.warnings] };
996
+ if (options.scan !== false) {
997
+ const scannedWarnings = new Set();
998
+ const scannableSkills = [];
999
+ for (const skill of skills) {
1000
+ const scan = await this.skillScanner.scanSkill(skill);
1001
+ if (scan.findings.length === 0) {
1002
+ scannableSkills.push(skill);
1003
+ continue;
1004
+ }
1005
+ if (scan.blocked && !options.allowRisky) {
1006
+ result.skipped.push({
1007
+ skill,
1008
+ reason: this.skillScanner.formatBlockingReason(scan),
1009
+ });
1010
+ continue;
1011
+ }
1012
+ const summary = this.skillScanner.formatShortSummary(scan);
1013
+ scannedWarnings.add(scan.blocked
1014
+ ? `Proceeding with "${skill.name}" despite high-risk findings: ${summary}`
1015
+ : `Security warnings for "${skill.name}": ${summary}`);
1016
+ scannableSkills.push(skill);
1017
+ }
1018
+ skills = scannableSkills;
1019
+ result.warnings.push(...scannedWarnings);
729
1020
  }
730
1021
  const installToSharedStore = options.agents?.includes(SHARED_SKILLS_TARGET_ID) ?? false;
731
1022
  const agents = await this.getTargetAgents(projectPath, options);
@@ -734,13 +1025,15 @@ export class SkillsManager {
734
1025
  installed: [],
735
1026
  updated: [],
736
1027
  unchanged: [],
737
- skipped: skills.map(skill => ({ skill, reason: 'No target agents found' })),
738
- warnings: context.warnings,
1028
+ skipped: [
1029
+ ...result.skipped,
1030
+ ...skills.map(skill => ({ skill, reason: 'No target agents found' })),
1031
+ ],
1032
+ warnings: result.warnings,
739
1033
  };
740
1034
  }
741
- const result = { installed: [], updated: [], unchanged: [], skipped: [], warnings: context.warnings };
742
1035
  const installableAgents = [];
743
- // Cache comparison results by canonical path to avoid re-comparing for multiple agents
1036
+ // Cache comparison results by install path to avoid re-comparing the same target
744
1037
  const comparisonCache = new Map();
745
1038
  // Collect pending updates to prompt user once for all of them
746
1039
  const pendingUpdates = [];
@@ -795,10 +1088,10 @@ export class SkillsManager {
795
1088
  ...(options.copy !== undefined ? { copy: options.copy } : {}),
796
1089
  };
797
1090
  const plan = await this.getInstallPlan(skill.name, agent, projectPath, installOptions);
798
- const comparisonPath = plan.canonicalPath || plan.path;
1091
+ const comparisonPath = plan.path;
799
1092
  let comparison = comparisonCache.get(comparisonPath);
800
1093
  if (comparison === undefined) {
801
- comparison = await this.compareSkillSnapshot(skill, comparisonPath);
1094
+ comparison = await this.compareSkillInstallStatus(skill, plan);
802
1095
  comparisonCache.set(comparisonPath, comparison);
803
1096
  }
804
1097
  if (comparison === 'unchanged') {
@@ -857,9 +1150,50 @@ export class SkillsManager {
857
1150
  }
858
1151
  }
859
1152
  }
1153
+ // Record to global install lock
1154
+ try {
1155
+ const lock = new InstallLock();
1156
+ const fromOption = options.from;
1157
+ const { source: resolvedSource } = this.resolveSourceRequest(source, ...(fromOption ? [{ from: fromOption }] : []));
1158
+ const lockSource = {
1159
+ type: resolvedSource.type,
1160
+ ...(resolvedSource.marketplace ? { marketplace: resolvedSource.marketplace } : {}),
1161
+ ...(resolvedSource.pluginName ? { pluginName: resolvedSource.pluginName } : {}),
1162
+ ...(normalizedPrefix ? { prefix: normalizedPrefix } : {}),
1163
+ ...(resolvedSource.url ? { url: resolvedSource.url } : {}),
1164
+ ...(resolvedSource.path ? { path: resolve(projectPath, expandTilde(resolvedSource.path)) } : {}),
1165
+ ...(resolvedSource.owner ? { owner: resolvedSource.owner } : {}),
1166
+ ...(resolvedSource.repo ? { repo: resolvedSource.repo } : {}),
1167
+ ...(resolvedSource.subpath ? { subpath: resolvedSource.subpath } : {}),
1168
+ };
1169
+ const recordEntries = async (entries, action) => {
1170
+ for (const entry of entries) {
1171
+ const hashPath = entry.canonicalPath || entry.path;
1172
+ const contentHash = await hashDirectory(hashPath);
1173
+ await lock.recordSkill({
1174
+ action,
1175
+ name: entry.skill.name,
1176
+ projectPath: resolve(projectPath),
1177
+ agents: [entry.agent],
1178
+ scope: options.global ? 'global' : 'project',
1179
+ source: lockSource,
1180
+ installPath: entry.path,
1181
+ mode: entry.mode,
1182
+ ...(entry.canonicalPath ? { canonicalPath: entry.canonicalPath } : {}),
1183
+ ...(contentHash ? { contentHash } : {}),
1184
+ });
1185
+ }
1186
+ };
1187
+ await recordEntries(result.installed, 'install');
1188
+ await recordEntries(result.updated, 'update');
1189
+ }
1190
+ catch (error) {
1191
+ logLockWriteWarning('Skills changed successfully', error);
1192
+ }
860
1193
  return result;
861
1194
  }
862
1195
  finally {
1196
+ await prefixedCleanup();
863
1197
  await context.cleanup();
864
1198
  }
865
1199
  }
@@ -1060,6 +1394,31 @@ export class SkillsManager {
1060
1394
  notFound.push(name);
1061
1395
  }
1062
1396
  }
1397
+ // Record removals to global install lock
1398
+ try {
1399
+ if (targetedEntries.length > 0) {
1400
+ const lock = new InstallLock();
1401
+ for (const entry of targetedEntries) {
1402
+ const entryKey = `${entry.agent}:${entry.name}`;
1403
+ if (!removed.includes(entryKey))
1404
+ continue;
1405
+ await lock.recordSkill({
1406
+ action: 'remove',
1407
+ name: entry.name,
1408
+ projectPath: resolve(projectPath),
1409
+ agents: [entry.agent],
1410
+ scope: entry.scope,
1411
+ source: { type: 'local', path: entry.path },
1412
+ installPath: entry.path,
1413
+ mode: entry.mode,
1414
+ ...(entry.canonicalPath ? { canonicalPath: entry.canonicalPath } : {}),
1415
+ });
1416
+ }
1417
+ }
1418
+ }
1419
+ catch (error) {
1420
+ logLockWriteWarning('Skills removed successfully', error);
1421
+ }
1063
1422
  return { removed, notFound, skipped };
1064
1423
  }
1065
1424
  }