skills-package-manager 0.7.0 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -11,6 +11,8 @@ npx skills-package-manager --help
11
11
  npx skills-package-manager --version
12
12
  npx skills-package-manager add <specifier> [--skill <name>]
13
13
  npx skills-package-manager install
14
+ npx skills-package-manager patch <skill>
15
+ npx skills-package-manager patch-commit <edit-dir>
14
16
  npx skills-package-manager update [skill...]
15
17
  npx skills-package-manager init [--yes]
16
18
  ```
@@ -104,6 +106,40 @@ npx skills-package-manager install
104
106
 
105
107
  This resolves each skill from its specifier, materializes it into `installDir` (default `.agents/skills/`), and creates symlinks for each `linkTarget`.
106
108
  When `selfSkill` is `true`, `npx skills-package-manager install` also installs the bundled `skills-package-manager-cli` skill so users get guidance for `skills.json`, `skills-lock.yaml`, and `npx skills-package-manager` commands. This helper skill is not written to `skills-lock.yaml`.
109
+ If `patchedSkills` contains an entry for a skill, the corresponding patch file is applied after the skill is materialized.
110
+
111
+ ### `npx skills-package-manager patch`
112
+
113
+ Prepare a skill for patching without changing the manifest yet:
114
+
115
+ ```bash
116
+ npx skills-package-manager patch hello-skill
117
+ npx skills-package-manager patch hello-skill --edit-dir ./tmp/hello-skill
118
+ ```
119
+
120
+ Behavior:
121
+
122
+ - Resolves the currently locked content for the target skill
123
+ - Extracts an editable copy into a temporary directory by default
124
+ - Reapplies any committed patch for that skill unless `--ignore-existing` is passed
125
+ - Writes patch edit metadata so `patch-commit` can generate a new patch file later
126
+
127
+ ### `npx skills-package-manager patch-commit`
128
+
129
+ Commit an edited patch directory back into the project:
130
+
131
+ ```bash
132
+ npx skills-package-manager patch-commit /tmp/skills-pm-patch-hello-skill-12345
133
+ npx skills-package-manager patch-commit ./tmp/hello-skill --patches-dir ./custom-patches
134
+ ```
135
+
136
+ Behavior:
137
+
138
+ - Compares the edited directory with the original resolved skill content
139
+ - Writes a unified diff patch file to `patches/<skill>.patch` by default
140
+ - Updates `skills.json` through the `patchedSkills` field
141
+ - Updates `skills-lock.yaml` with patch path and digest metadata
142
+ - Reinstalls and relinks the patched skill so the working tree reflects the committed patch
107
143
 
108
144
  ### `npx skills-package-manager update`
109
145
 
@@ -172,10 +208,11 @@ link: link:<path-to-skill-dir>
172
208
  src/
173
209
  ├── bin/ # CLI entry points
174
210
  ├── cli/ # CLI runner and interactive prompts
175
- ├── commands/ # add, install command implementations
211
+ ├── commands/ # add, install, patch command implementations
176
212
  ├── config/ # skills.json / skills-lock.yaml read/write
177
213
  ├── github/ # Git clone + skill discovery (listSkills)
178
214
  ├── install/ # Skill materialization, linking, pruning
215
+ ├── patches/ # Patch edit state, diff generation, patch application
179
216
  ├── specifiers/ # Specifier parsing and normalization
180
217
  └── utils/ # Hashing, filesystem helpers
181
218
  ```
@@ -0,0 +1,466 @@
1
+ export declare function addCommand(options: AddCommandOptions): Promise<{
2
+ skillName: string;
3
+ specifier: string;
4
+ } | {
5
+ skillName: string;
6
+ specifier: string;
7
+ }[]>;
8
+
9
+ export declare type AddCommandOptions = {
10
+ cwd: string;
11
+ specifier: string;
12
+ skill?: string;
13
+ global?: boolean;
14
+ yes?: boolean;
15
+ agent?: string[];
16
+ };
17
+
18
+ /**
19
+ * Clone a git repo (shallow) into a temp dir, discover skills, then clean up.
20
+ */
21
+ export declare function cloneAndDiscover(gitUrl: string, ref?: string): Promise<{
22
+ skills: SkillInfo[];
23
+ cleanup: () => Promise<void>;
24
+ }>;
25
+
26
+ /**
27
+ * Converts a Node.js file system error to an appropriate SPM error type
28
+ */
29
+ export declare function convertNodeError(error: NodeJS.ErrnoException, context: {
30
+ operation: string;
31
+ path: string;
32
+ }): FileSystemError;
33
+
34
+ export declare function createInstallProgressReporter(options?: ProgressReporterOptions): InstallProgressReporter;
35
+
36
+ /**
37
+ * Discover skills in a local directory by scanning for SKILL.md files.
38
+ * Recursively scans the repo tree for directories containing SKILL.md.
39
+ */
40
+ export declare function discoverSkillsInDir(baseDir: string): Promise<SkillInfo[]>;
41
+
42
+ /**
43
+ * Error codes for SPM (Skills Package Manager)
44
+ * Inspired by pnpm's error code system
45
+ */
46
+ export declare enum ErrorCode {
47
+ FILE_NOT_FOUND = "ENOENT",
48
+ PERMISSION_DENIED = "EACCES",
49
+ FILE_EXISTS = "EEXIST",
50
+ FS_ERROR = "EFS",
51
+ GIT_CLONE_FAILED = "EGITCLONE",
52
+ GIT_FETCH_FAILED = "EGITFETCH",
53
+ GIT_CHECKOUT_FAILED = "EGITCHECKOUT",
54
+ GIT_REF_NOT_FOUND = "EGITREF",
55
+ GIT_NOT_INSTALLED = "EGITNOTFOUND",
56
+ PARSE_ERROR = "EPARSE",
57
+ JSON_PARSE_ERROR = "EJSONPARSE",
58
+ YAML_PARSE_ERROR = "EYAMLPARSE",
59
+ INVALID_SPECIFIER = "EINVALIDSPEC",
60
+ MANIFEST_NOT_FOUND = "EMANIFEST",
61
+ LOCKFILE_NOT_FOUND = "ELOCKFILE",
62
+ LOCKFILE_OUTDATED = "ELOCKOUTDATED",
63
+ MANIFEST_EXISTS = "EMANIFESTEXISTS",
64
+ MANIFEST_VALIDATION_ERROR = "EMANIFESTVAL",
65
+ NETWORK_ERROR = "ENETWORK",
66
+ REPO_NOT_FOUND = "EREPONOTFOUND",
67
+ UNKNOWN_ERROR = "EUNKNOWN",
68
+ NOT_IMPLEMENTED = "ENOTIMPL",
69
+ VALIDATION_ERROR = "EVALIDATION",
70
+ SKILL_NOT_FOUND = "ESKILLNOTFOUND",
71
+ SKILL_EXISTS = "ESKILLEXISTS"
72
+ }
73
+
74
+ export declare function expandSkillsManifest(_rootDir: string, manifest: SkillsManifest): Promise<NormalizedSkillsManifest>;
75
+
76
+ export declare function fetchSkillsFromLock(rootDir: string, manifest: SkillsManifest, lockfile: SkillsLock, options?: {
77
+ onProgress?: InstallProgressListener;
78
+ }): Promise<{
79
+ readonly status: "skipped";
80
+ readonly reason: "up-to-date";
81
+ readonly fetched?: undefined;
82
+ } | {
83
+ readonly status: "fetched";
84
+ readonly fetched: string[];
85
+ readonly reason?: undefined;
86
+ }>;
87
+
88
+ /**
89
+ * Error thrown when file system operations fail
90
+ */
91
+ export declare class FileSystemError extends SpmError {
92
+ readonly operation: string;
93
+ readonly path: string;
94
+ constructor(options: {
95
+ code: ErrorCode.FILE_NOT_FOUND | ErrorCode.PERMISSION_DENIED | ErrorCode.FILE_EXISTS | ErrorCode.FS_ERROR;
96
+ operation: 'read' | 'write' | 'access' | 'mkdir' | 'rm' | 'copy' | 'symlink' | string;
97
+ path: string;
98
+ message?: string;
99
+ cause?: Error;
100
+ });
101
+ }
102
+
103
+ /**
104
+ * Formats an error for display to the user
105
+ * Provides helpful context for known error types
106
+ */
107
+ export declare function formatErrorForDisplay(error: unknown): string;
108
+
109
+ /**
110
+ * Gets the exit code for an error
111
+ * Returns 1 for general errors, specific codes for known error types
112
+ */
113
+ export declare function getExitCode(error: unknown): number;
114
+
115
+ /**
116
+ * Error thrown when git operations fail
117
+ */
118
+ export declare class GitError extends SpmError {
119
+ readonly operation: string;
120
+ readonly repoUrl?: string;
121
+ readonly ref?: string;
122
+ constructor(options: {
123
+ code: ErrorCode.GIT_CLONE_FAILED | ErrorCode.GIT_FETCH_FAILED | ErrorCode.GIT_CHECKOUT_FAILED | ErrorCode.GIT_REF_NOT_FOUND | ErrorCode.GIT_NOT_INSTALLED;
124
+ operation: 'clone' | 'fetch' | 'checkout' | 'ls-remote' | 'rev-parse' | string;
125
+ repoUrl?: string;
126
+ ref?: string;
127
+ message?: string;
128
+ cause?: Error;
129
+ });
130
+ }
131
+
132
+ export declare function initCommand(options: InitCommandOptions, promptInit?: InitPrompter): Promise<SkillsManifest>;
133
+
134
+ export declare type InitCommandOptions = {
135
+ cwd: string;
136
+ yes?: boolean;
137
+ };
138
+
139
+ declare type InitPrompter = () => Promise<InitPromptResult>;
140
+
141
+ declare type InitPromptResult = {
142
+ installDir: string;
143
+ linkTargets: string[];
144
+ };
145
+
146
+ export declare function installCommand(options: InstallCommandOptions): Promise<{
147
+ readonly status: "installed";
148
+ readonly installed: string[];
149
+ }>;
150
+
151
+ export declare type InstallCommandOptions = {
152
+ cwd: string;
153
+ frozenLockfile?: boolean;
154
+ };
155
+
156
+ declare type InstallPhase = 'resolving' | 'fetching' | 'linking' | 'finalizing' | 'done';
157
+
158
+ export declare type InstallProgressEvent = {
159
+ type: 'resolved';
160
+ skillName: string;
161
+ } | {
162
+ type: 'added';
163
+ skillName: string;
164
+ } | {
165
+ type: 'installed';
166
+ skillName: string;
167
+ };
168
+
169
+ export declare type InstallProgressListener = (event: InstallProgressEvent) => void;
170
+
171
+ declare type InstallProgressReporter = {
172
+ start(total: number): void;
173
+ setPhase(phase: Exclude<InstallPhase, 'done'>): void;
174
+ onProgress(event: InstallProgressEvent): void;
175
+ complete(): void;
176
+ fail(): void;
177
+ };
178
+
179
+ export declare function installSkills(rootDir: string, options?: {
180
+ frozenLockfile?: boolean;
181
+ onProgress?: InstallProgressListener;
182
+ }): Promise<{
183
+ readonly status: "skipped";
184
+ readonly reason: "manifest-missing";
185
+ readonly installed?: undefined;
186
+ } | {
187
+ readonly status: "installed";
188
+ readonly installed: string[];
189
+ readonly reason?: undefined;
190
+ }>;
191
+
192
+ export declare const installStageHooks: {
193
+ beforeFetch: (_rootDir: string, _manifest: SkillsManifest, _lockfile: SkillsLock) => Promise<void>;
194
+ };
195
+
196
+ export declare function isLockInSync(rootDir: string, manifest: NormalizedSkillsManifest, lock: SkillsLock | null): Promise<boolean>;
197
+
198
+ /**
199
+ * Checks if an error is a known SPM error
200
+ */
201
+ export declare function isSpmError(error: unknown): error is SpmError;
202
+
203
+ export declare function linkSkillsFromLock(rootDir: string, manifest: SkillsManifest, lockfile: SkillsLock, options?: {
204
+ onProgress?: InstallProgressListener;
205
+ }): Promise<{
206
+ readonly status: "linked";
207
+ readonly linked: string[];
208
+ }>;
209
+
210
+ /**
211
+ * List skills in a GitHub repo by cloning and scanning.
212
+ * This avoids GitHub API rate limits.
213
+ */
214
+ export declare function listRepoSkills(owner: string, repo: string, ref?: string): Promise<SkillInfo[]>;
215
+
216
+ /**
217
+ * Error thrown when manifest or lockfile operations fail
218
+ */
219
+ export declare class ManifestError extends SpmError {
220
+ readonly filePath: string;
221
+ constructor(options: {
222
+ code: ErrorCode.MANIFEST_NOT_FOUND | ErrorCode.LOCKFILE_NOT_FOUND | ErrorCode.LOCKFILE_OUTDATED | ErrorCode.MANIFEST_EXISTS | ErrorCode.MANIFEST_VALIDATION_ERROR;
223
+ filePath: string;
224
+ message?: string;
225
+ cause?: Error;
226
+ });
227
+ }
228
+
229
+ /**
230
+ * Error thrown when network operations fail
231
+ */
232
+ export declare class NetworkError extends SpmError {
233
+ readonly url?: string;
234
+ constructor(options: {
235
+ code: ErrorCode.NETWORK_ERROR | ErrorCode.REPO_NOT_FOUND;
236
+ url?: string;
237
+ message: string;
238
+ cause?: Error;
239
+ });
240
+ }
241
+
242
+ /**
243
+ * Skills manifest output type after validation/default application.
244
+ * Use this for normalized manifests returned from reads/parsing.
245
+ */
246
+ declare type NormalizedSkillsManifest = {
247
+ $schema?: string;
248
+ installDir: string;
249
+ linkTargets: string[];
250
+ selfSkill?: boolean;
251
+ skills: Record<string, string>;
252
+ patchedSkills?: Record<string, string>;
253
+ };
254
+
255
+ export declare type NormalizedSpecifier = {
256
+ type: 'git' | 'link' | 'file' | 'npm';
257
+ source: string;
258
+ ref: string | null;
259
+ path: string;
260
+ normalized: string;
261
+ skillName: string;
262
+ };
263
+
264
+ export declare function normalizeSkillsManifest(manifest: Partial<SkillsManifest>): NormalizedSkillsManifest;
265
+
266
+ export declare function normalizeSpecifier(specifier: string): NormalizedSpecifier;
267
+
268
+ /**
269
+ * Error thrown when parsing fails (JSON, YAML, specifiers)
270
+ */
271
+ export declare class ParseError extends SpmError {
272
+ readonly filePath?: string;
273
+ readonly content?: string;
274
+ constructor(options: {
275
+ code: ErrorCode.PARSE_ERROR | ErrorCode.JSON_PARSE_ERROR | ErrorCode.YAML_PARSE_ERROR | ErrorCode.INVALID_SPECIFIER;
276
+ filePath?: string;
277
+ content?: string;
278
+ message: string;
279
+ cause?: Error;
280
+ });
281
+ }
282
+
283
+ export declare function parseGitHubUrl(input: string): {
284
+ owner: string;
285
+ repo: string;
286
+ } | null;
287
+
288
+ export declare function parseOwnerRepo(input: string): {
289
+ owner: string;
290
+ repo: string;
291
+ } | null;
292
+
293
+ export declare function parseSpecifier(specifier: string): {
294
+ sourcePart: string;
295
+ ref: string | null;
296
+ path: string;
297
+ };
298
+
299
+ export declare function patchCommand(options: PatchCommandOptions): Promise<PatchCommandResult>;
300
+
301
+ export declare type PatchCommandOptions = {
302
+ cwd: string;
303
+ skillName: string;
304
+ editDir?: string;
305
+ ignoreExisting?: boolean;
306
+ };
307
+
308
+ export declare type PatchCommandResult = {
309
+ status: 'patched';
310
+ skillName: string;
311
+ editDir: string;
312
+ originalSpecifier: string;
313
+ };
314
+
315
+ export declare function patchCommitCommand(options: PatchCommitCommandOptions): Promise<PatchCommitCommandResult>;
316
+
317
+ export declare type PatchCommitCommandOptions = {
318
+ cwd: string;
319
+ editDir: string;
320
+ patchesDir?: string;
321
+ };
322
+
323
+ export declare type PatchCommitCommandResult = {
324
+ status: 'patched';
325
+ skillName: string;
326
+ patchFile: string;
327
+ };
328
+
329
+ declare type ProgressReporterOptions = {
330
+ isTTY?: boolean;
331
+ write?: (text: string) => void;
332
+ info?: (text: string) => void;
333
+ };
334
+
335
+ export declare function readSkillsLock(rootDir: string): Promise<SkillsLock | null>;
336
+
337
+ export declare function readSkillsManifest(rootDir: string): Promise<NormalizedSkillsManifest | null>;
338
+
339
+ export declare function resolveLockEntry(cwd: string, specifier: string, skillName?: string): Promise<{
340
+ skillName: string;
341
+ entry: SkillsLockEntry;
342
+ }>;
343
+
344
+ export declare function runCli(argv: string[], context?: {
345
+ cwd?: string;
346
+ }): Promise<unknown>;
347
+
348
+ /**
349
+ * Error thrown when a skill operation fails
350
+ */
351
+ export declare class SkillError extends SpmError {
352
+ readonly skillName: string;
353
+ constructor(options: {
354
+ code: ErrorCode.SKILL_NOT_FOUND | ErrorCode.SKILL_EXISTS | ErrorCode.VALIDATION_ERROR;
355
+ skillName: string;
356
+ message?: string;
357
+ cause?: Error;
358
+ });
359
+ }
360
+
361
+ export declare type SkillInfo = {
362
+ name: string;
363
+ description: string;
364
+ path: string;
365
+ };
366
+
367
+ export declare type SkillsLock = {
368
+ lockfileVersion: '0.1';
369
+ installDir: string;
370
+ linkTargets: string[];
371
+ skills: Record<string, SkillsLockEntry>;
372
+ };
373
+
374
+ export declare type SkillsLockEntry = {
375
+ specifier: string;
376
+ resolution: {
377
+ type: 'link';
378
+ path: string;
379
+ } | {
380
+ type: 'file';
381
+ tarball: string;
382
+ path: string;
383
+ } | {
384
+ type: 'git';
385
+ url: string;
386
+ commit: string;
387
+ path: string;
388
+ } | {
389
+ type: 'npm';
390
+ packageName: string;
391
+ version: string;
392
+ path: string;
393
+ tarball: string;
394
+ integrity?: string;
395
+ registry?: string;
396
+ };
397
+ digest: string;
398
+ patch?: {
399
+ path: string;
400
+ digest: string;
401
+ };
402
+ };
403
+
404
+ /**
405
+ * Skills manifest input type used for authoring/writing manifests.
406
+ * This preserves optionality for fields with defaults.
407
+ */
408
+ export declare type SkillsManifest = {
409
+ $schema?: string;
410
+ installDir?: string;
411
+ linkTargets?: string[];
412
+ selfSkill?: boolean;
413
+ skills?: Record<string, string>;
414
+ patchedSkills?: Record<string, string>;
415
+ };
416
+
417
+ /**
418
+ * Base error class for SPM (Skills Package Manager)
419
+ * All custom errors should extend this class
420
+ */
421
+ export declare class SpmError extends Error {
422
+ readonly code: ErrorCode;
423
+ readonly cause?: Error;
424
+ readonly context: Record<string, unknown>;
425
+ constructor(options: {
426
+ code: ErrorCode;
427
+ message: string;
428
+ cause?: Error;
429
+ context?: Record<string, unknown>;
430
+ });
431
+ /**
432
+ * Returns a formatted string representation of the error
433
+ */
434
+ toString(): string;
435
+ /**
436
+ * Returns a detailed object representation for logging/debugging
437
+ */
438
+ toJSON(): Record<string, unknown>;
439
+ }
440
+
441
+ export declare function updateCommand(options: UpdateCommandOptions): Promise<UpdateCommandResult>;
442
+
443
+ export declare type UpdateCommandOptions = {
444
+ cwd: string;
445
+ skills?: string[];
446
+ };
447
+
448
+ export declare type UpdateCommandResult = {
449
+ status: 'updated' | 'skipped' | 'failed';
450
+ updated: string[];
451
+ unchanged: string[];
452
+ skipped: Array<{
453
+ name: string;
454
+ reason: 'link-specifier';
455
+ }>;
456
+ failed: Array<{
457
+ name: string;
458
+ reason: string;
459
+ }>;
460
+ };
461
+
462
+ export declare function writeSkillsLock(rootDir: string, lockfile: SkillsLock): Promise<void>;
463
+
464
+ export declare function writeSkillsManifest(rootDir: string, manifest: SkillsManifest): Promise<void>;
465
+
466
+ export { }
package/dist/index.js CHANGED
@@ -16,7 +16,7 @@ import { co } from "./npm-tar.js";
16
16
  import { cac } from "./npm-cac.js";
17
17
  import { Ct } from "./npm-clack_core.js";
18
18
  var package_namespaceObject = {
19
- rE: "0.7.0"
19
+ rE: "0.8.0"
20
20
  };
21
21
  function getHomeDir() {
22
22
  return homedir();
@@ -721,7 +721,8 @@ const skillsManifestSchema = schemas_object({
721
721
  installDir: schemas_string().default('.agents/skills').describe('Directory where skills will be installed'),
722
722
  linkTargets: schemas_array(schemas_string()).default([]).describe('Directories where skill symlinks will be created'),
723
723
  selfSkill: schemas_boolean().optional().describe('Whether this project is itself a skill'),
724
- skills: record(schemas_string(), schemas_string()).default({}).describe('Map of skill names to their specifiers')
724
+ skills: record(schemas_string(), schemas_string()).default({}).describe('Map of skill names to their specifiers'),
725
+ patchedSkills: record(schemas_string(), schemas_string()).optional().describe('Map of skill names to their patch file paths')
725
726
  }).strict();
726
727
  async function readSkillsManifest(rootDir) {
727
728
  const filePath = node_path.join(rootDir, 'skills.json');
@@ -1093,11 +1094,11 @@ async function sha256File(filePath, suffix = '') {
1093
1094
  if (suffix) hash.update(suffix);
1094
1095
  return `sha256-${hash.digest('hex')}`;
1095
1096
  }
1096
- const execFileAsync = promisify(execFile);
1097
1097
  function toPortableRelativePath(from, to) {
1098
1098
  const relativePath = node_path.relative(from, to) || '.';
1099
1099
  return '/' === node_path.sep ? relativePath : relativePath.split(node_path.sep).join('/');
1100
1100
  }
1101
+ const execFileAsync = promisify(execFile);
1101
1102
  async function resolveGitCommitByLsRemote(url, target) {
1102
1103
  try {
1103
1104
  const { stdout } = await execFileAsync('git', [
@@ -1247,16 +1248,29 @@ async function resolveLockEntry(cwd, specifier, skillName) {
1247
1248
  content: specifier
1248
1249
  });
1249
1250
  }
1251
+ async function attachManifestPatchToEntry(cwd, manifest, skillName, entry) {
1252
+ const patchPath = manifest.patchedSkills?.[skillName];
1253
+ if (!patchPath) return entry;
1254
+ const absolutePatchPath = node_path.resolve(cwd, patchPath);
1255
+ return {
1256
+ ...entry,
1257
+ patch: {
1258
+ path: toPortableRelativePath(cwd, absolutePatchPath),
1259
+ digest: await sha256File(absolutePatchPath)
1260
+ }
1261
+ };
1262
+ }
1250
1263
  async function syncSkillsLock(cwd, manifest, _existingLock, options) {
1251
1264
  const entries = await Promise.all(Object.entries(manifest.skills).map(async ([skillName, specifier])=>{
1252
1265
  const { skillName: resolvedName, entry } = await resolveLockEntry(cwd, specifier, skillName);
1266
+ const entryWithPatch = await attachManifestPatchToEntry(cwd, manifest, resolvedName, entry);
1253
1267
  options?.onProgress?.({
1254
1268
  type: 'resolved',
1255
1269
  skillName: resolvedName
1256
1270
  });
1257
1271
  return [
1258
1272
  resolvedName,
1259
- entry
1273
+ entryWithPatch
1260
1274
  ];
1261
1275
  }));
1262
1276
  const nextSkills = Object.fromEntries(entries);
@@ -1288,6 +1302,7 @@ async function writeSkillsManifest(rootDir, manifest) {
1288
1302
  skills: manifest.skills
1289
1303
  };
1290
1304
  if (void 0 !== manifest.selfSkill) nextManifest.selfSkill = manifest.selfSkill;
1305
+ if (manifest.patchedSkills && Object.keys(manifest.patchedSkills).length > 0) nextManifest.patchedSkills = manifest.patchedSkills;
1291
1306
  try {
1292
1307
  await writeFile(filePath, `${JSON.stringify(nextManifest, null, 2)}\n`, 'utf8');
1293
1308
  } catch (error) {
@@ -1470,17 +1485,31 @@ function arraysEqual(a, b) {
1470
1485
  if (a.length !== b.length) return false;
1471
1486
  return a.every((val, i)=>val === b[i]);
1472
1487
  }
1473
- function isLockInSync(manifest, lock) {
1488
+ async function isPatchInSync(rootDir, manifest, skillName, lock) {
1489
+ const lockEntry = lock.skills[skillName];
1490
+ if (!lockEntry) return false;
1491
+ const manifestPatchPath = manifest.patchedSkills?.[skillName];
1492
+ if (!manifestPatchPath) return void 0 === lockEntry.patch;
1493
+ if (!lockEntry.patch) return false;
1494
+ const absolutePatchPath = node_path.resolve(rootDir, manifestPatchPath);
1495
+ const normalizedPatchPath = toPortableRelativePath(rootDir, absolutePatchPath);
1496
+ if (lockEntry.patch.path !== normalizedPatchPath) return false;
1497
+ return lockEntry.patch.digest === await sha256File(absolutePatchPath);
1498
+ }
1499
+ async function isLockInSync(rootDir, manifest, lock) {
1474
1500
  if (!lock) return false;
1475
1501
  if (normalizeInstallDir(manifest.installDir) !== normalizeInstallDir(lock.installDir)) return false;
1476
1502
  if (!arraysEqual(normalizeLinkTargets(manifest.linkTargets), normalizeLinkTargets(lock.linkTargets))) return false;
1477
1503
  const manifestSkills = Object.entries(manifest.skills);
1478
1504
  const lockSkillNames = Object.keys(lock.skills);
1505
+ const patchedSkillNames = Object.keys(manifest.patchedSkills ?? {});
1479
1506
  if (manifestSkills.length !== lockSkillNames.length) return false;
1507
+ if (patchedSkillNames.some((skillName)=>!(skillName in manifest.skills))) return false;
1480
1508
  for (const [name, specifier] of manifestSkills){
1481
1509
  const lockEntry = lock.skills[name];
1482
1510
  if (!lockEntry) return false;
1483
1511
  if (!isSpecifierCompatible(specifier, lockEntry.specifier)) return false;
1512
+ if (!await isPatchInSync(rootDir, manifest, name, lock)) return false;
1484
1513
  }
1485
1514
  return true;
1486
1515
  }
@@ -1522,7 +1551,8 @@ function normalizeSkillsManifest(manifest) {
1522
1551
  installDir: manifest.installDir ?? '.agents/skills',
1523
1552
  linkTargets: manifest.linkTargets ?? [],
1524
1553
  selfSkill: manifest.selfSkill ?? false,
1525
- skills: manifest.skills ?? {}
1554
+ skills: manifest.skills ?? {},
1555
+ patchedSkills: manifest.patchedSkills
1526
1556
  };
1527
1557
  }
1528
1558
  async function expandSkillsManifest(_rootDir, manifest) {
@@ -1538,6 +1568,172 @@ async function expandSkillsManifest(_rootDir, manifest) {
1538
1568
  }
1539
1569
  };
1540
1570
  }
1571
+ const skillPatch_execFileAsync = promisify(execFile);
1572
+ const PATCH_EDIT_STATE_FILE = '.skills-pm-patch.json';
1573
+ async function writePatchEditState(editDir, state) {
1574
+ const filePath = node_path.join(editDir, PATCH_EDIT_STATE_FILE);
1575
+ await writeFile(filePath, `${JSON.stringify(state, null, 2)}\n`, 'utf8');
1576
+ }
1577
+ async function readPatchEditState(editDir) {
1578
+ const filePath = node_path.join(editDir, PATCH_EDIT_STATE_FILE);
1579
+ let raw;
1580
+ try {
1581
+ raw = await readFile(filePath, 'utf8');
1582
+ } catch (error) {
1583
+ throw convertNodeError(error, {
1584
+ operation: 'read',
1585
+ path: filePath
1586
+ });
1587
+ }
1588
+ try {
1589
+ return JSON.parse(raw);
1590
+ } catch (error) {
1591
+ throw new ParseError({
1592
+ code: codes_ErrorCode.JSON_PARSE_ERROR,
1593
+ filePath,
1594
+ content: raw,
1595
+ message: `Failed to parse patch edit state: ${error.message}`,
1596
+ cause: error
1597
+ });
1598
+ }
1599
+ }
1600
+ async function clearDirectoryExceptGit(rootDir) {
1601
+ const entries = await readdir(rootDir, {
1602
+ withFileTypes: true
1603
+ });
1604
+ await Promise.all(entries.filter((entry)=>'.git' !== entry.name).map((entry)=>rm(node_path.join(rootDir, entry.name), {
1605
+ recursive: true,
1606
+ force: true
1607
+ })));
1608
+ }
1609
+ async function copySkillDir(from, to) {
1610
+ await cp(from, to, {
1611
+ recursive: true,
1612
+ filter: (source)=>{
1613
+ const baseName = node_path.basename(source);
1614
+ return baseName !== PATCH_EDIT_STATE_FILE && '.git' !== baseName && '.hg' !== baseName;
1615
+ }
1616
+ });
1617
+ }
1618
+ async function runGitCommand(args, options) {
1619
+ try {
1620
+ return await skillPatch_execFileAsync('git', args, {
1621
+ cwd: options.cwd,
1622
+ ...options.maxBuffer ? {
1623
+ maxBuffer: options.maxBuffer
1624
+ } : {}
1625
+ });
1626
+ } catch (error) {
1627
+ const nodeError = error;
1628
+ if ('ENOENT' === nodeError.code) throw new GitError({
1629
+ code: codes_ErrorCode.GIT_NOT_INSTALLED,
1630
+ operation: options.operation,
1631
+ message: 'git is required to create and apply skill patches',
1632
+ cause: error
1633
+ });
1634
+ throw new GitError({
1635
+ code: codes_ErrorCode.GIT_FETCH_FAILED,
1636
+ operation: options.operation,
1637
+ message: options.message,
1638
+ cause: error
1639
+ });
1640
+ }
1641
+ }
1642
+ async function applySkillPatch(targetDir, patchFilePath) {
1643
+ try {
1644
+ await access(patchFilePath);
1645
+ } catch (error) {
1646
+ throw convertNodeError(error, {
1647
+ operation: 'read',
1648
+ path: patchFilePath
1649
+ });
1650
+ }
1651
+ await runGitCommand([
1652
+ 'apply',
1653
+ '--whitespace=nowarn',
1654
+ patchFilePath
1655
+ ], {
1656
+ cwd: targetDir,
1657
+ operation: 'apply',
1658
+ message: `Failed to apply patch ${patchFilePath}`
1659
+ });
1660
+ }
1661
+ async function generateSkillPatch(baseDir, editedDir) {
1662
+ const repoRoot = await mkdtemp(node_path.join(tmpdir(), 'skills-pm-patch-commit-'));
1663
+ try {
1664
+ await runGitCommand([
1665
+ 'init',
1666
+ '--quiet'
1667
+ ], {
1668
+ cwd: repoRoot,
1669
+ operation: 'init',
1670
+ message: 'Failed to initialize git repository for patch generation'
1671
+ });
1672
+ await runGitCommand([
1673
+ 'config',
1674
+ 'user.email',
1675
+ 'skills-package-manager@example.com'
1676
+ ], {
1677
+ cwd: repoRoot,
1678
+ operation: 'config',
1679
+ message: 'Failed to configure git user email for patch generation'
1680
+ });
1681
+ await runGitCommand([
1682
+ 'config',
1683
+ 'user.name',
1684
+ 'skills-package-manager'
1685
+ ], {
1686
+ cwd: repoRoot,
1687
+ operation: 'config',
1688
+ message: 'Failed to configure git user name for patch generation'
1689
+ });
1690
+ await copySkillDir(baseDir, repoRoot);
1691
+ await runGitCommand([
1692
+ 'add',
1693
+ '--all'
1694
+ ], {
1695
+ cwd: repoRoot,
1696
+ operation: 'add',
1697
+ message: 'Failed to stage base skill files for patch generation'
1698
+ });
1699
+ await runGitCommand([
1700
+ 'commit',
1701
+ '--quiet',
1702
+ '--allow-empty',
1703
+ '--no-gpg-sign',
1704
+ '-m',
1705
+ 'base'
1706
+ ], {
1707
+ cwd: repoRoot,
1708
+ operation: 'commit',
1709
+ message: 'Failed to create base commit for patch generation'
1710
+ });
1711
+ await clearDirectoryExceptGit(repoRoot);
1712
+ await copySkillDir(editedDir, repoRoot);
1713
+ const { stdout } = await runGitCommand([
1714
+ 'diff',
1715
+ '--binary',
1716
+ '--full-index',
1717
+ '--no-ext-diff',
1718
+ '--src-prefix=a/',
1719
+ '--dst-prefix=b/',
1720
+ 'HEAD',
1721
+ '--',
1722
+ '.'
1723
+ ], {
1724
+ cwd: repoRoot,
1725
+ operation: 'diff',
1726
+ message: 'Failed to generate skill patch',
1727
+ maxBuffer: 10485760
1728
+ });
1729
+ return stdout;
1730
+ } finally{
1731
+ await rm(repoRoot, {
1732
+ recursive: true,
1733
+ force: true
1734
+ }).catch(()=>{});
1735
+ }
1736
+ }
1541
1737
  async function ensureDir(dirPath) {
1542
1738
  await mkdir(dirPath, {
1543
1739
  recursive: true
@@ -1586,7 +1782,7 @@ async function linkSkill(rootDir, installDir, linkTarget, skillName) {
1586
1782
  await ensureDir(node_path.dirname(absoluteLink));
1587
1783
  await replaceSymlink(absoluteTarget, absoluteLink);
1588
1784
  }
1589
- async function materializeLocalSkill(rootDir, skillName, sourceRoot, sourcePath, installDir) {
1785
+ async function copyLocalSkillToDir(sourceRoot, sourcePath, targetDir) {
1590
1786
  const relativeSkillPath = sourcePath.replace(/^\//, '');
1591
1787
  const absoluteSkillPath = node_path.join(sourceRoot, relativeSkillPath);
1592
1788
  const skillDocPath = node_path.join(absoluteSkillPath, 'SKILL.md');
@@ -1597,15 +1793,21 @@ async function materializeLocalSkill(rootDir, skillName, sourceRoot, sourcePath,
1597
1793
  throw new Error(`Invalid skill at ${absoluteSkillPath}: missing SKILL.md`);
1598
1794
  }
1599
1795
  if (!skillDoc) throw new Error(`Invalid skill at ${absoluteSkillPath}: missing SKILL.md`);
1600
- const targetDir = node_path.join(rootDir, installDir, skillName);
1601
1796
  await ensureDir(node_path.dirname(targetDir));
1602
1797
  await replaceDir(absoluteSkillPath, targetDir);
1798
+ }
1799
+ async function writeInstalledSkillMarker(targetDir, skillName) {
1603
1800
  await writeJson(node_path.join(targetDir, '.skills-pm.json'), {
1604
1801
  name: skillName,
1605
1802
  installedBy: 'skills-package-manager',
1606
1803
  version: '0.1.0'
1607
1804
  });
1608
1805
  }
1806
+ async function materializeLocalSkill(rootDir, skillName, sourceRoot, sourcePath, installDir) {
1807
+ const targetDir = node_path.join(rootDir, installDir, skillName);
1808
+ await copyLocalSkillToDir(sourceRoot, sourcePath, targetDir);
1809
+ await writeInstalledSkillMarker(targetDir, skillName);
1810
+ }
1609
1811
  const materializeGitSkill_execFileAsync = promisify(execFile);
1610
1812
  async function checkoutCommit(checkoutRoot, commit) {
1611
1813
  try {
@@ -1665,7 +1867,7 @@ async function fetchCommitFallback(checkoutRoot, commit, _repoUrl) {
1665
1867
  });
1666
1868
  }
1667
1869
  }
1668
- async function materializeGitSkill(rootDir, skillName, repoUrl, commit, sourcePath, installDir) {
1870
+ async function extractGitSkillToDir(repoUrl, commit, sourcePath, targetDir) {
1669
1871
  const checkoutRoot = await mkdtemp(node_path.join(tmpdir(), 'skills-pm-git-checkout-'));
1670
1872
  try {
1671
1873
  try {
@@ -1698,7 +1900,7 @@ async function materializeGitSkill(rootDir, skillName, repoUrl, commit, sourcePa
1698
1900
  }
1699
1901
  const skillDocPath = node_path.join(checkoutRoot, sourcePath.replace(/^\//, ''), 'SKILL.md');
1700
1902
  await readFile(skillDocPath, 'utf8');
1701
- await materializeLocalSkill(rootDir, skillName, checkoutRoot, sourcePath, installDir);
1903
+ await copyLocalSkillToDir(checkoutRoot, sourcePath, targetDir);
1702
1904
  } finally{
1703
1905
  await rm(checkoutRoot, {
1704
1906
  recursive: true,
@@ -1706,7 +1908,12 @@ async function materializeGitSkill(rootDir, skillName, repoUrl, commit, sourcePa
1706
1908
  });
1707
1909
  }
1708
1910
  }
1709
- async function materializePackedSkill(rootDir, skillName, tarballPath, sourcePath, installDir) {
1911
+ async function materializeGitSkill(rootDir, skillName, repoUrl, commit, sourcePath, installDir) {
1912
+ const targetDir = node_path.join(rootDir, installDir, skillName);
1913
+ await extractGitSkillToDir(repoUrl, commit, sourcePath, targetDir);
1914
+ await writeInstalledSkillMarker(targetDir, skillName);
1915
+ }
1916
+ async function extractPackedSkillToDir(tarballPath, sourcePath, targetDir) {
1710
1917
  const extractRoot = await mkdtemp(node_path.join(tmpdir(), 'skills-pm-packed-skill-'));
1711
1918
  try {
1712
1919
  await mkdir(node_path.join(extractRoot, 'package'), {
@@ -1719,7 +1926,7 @@ async function materializePackedSkill(rootDir, skillName, tarballPath, sourcePat
1719
1926
  preservePaths: false,
1720
1927
  strict: true
1721
1928
  });
1722
- await materializeLocalSkill(rootDir, skillName, node_path.join(extractRoot, 'package'), sourcePath, installDir);
1929
+ await copyLocalSkillToDir(node_path.join(extractRoot, 'package'), sourcePath, targetDir);
1723
1930
  } finally{
1724
1931
  await rm(extractRoot, {
1725
1932
  recursive: true,
@@ -1727,6 +1934,11 @@ async function materializePackedSkill(rootDir, skillName, tarballPath, sourcePat
1727
1934
  }).catch(()=>{});
1728
1935
  }
1729
1936
  }
1937
+ async function materializePackedSkill(rootDir, skillName, tarballPath, sourcePath, installDir) {
1938
+ const targetDir = node_path.join(rootDir, installDir, skillName);
1939
+ await extractPackedSkillToDir(tarballPath, sourcePath, targetDir);
1940
+ await writeInstalledSkillMarker(targetDir, skillName);
1941
+ }
1730
1942
  function pruneManagedSkills_resolveTargetPath(rootDir, targetPath) {
1731
1943
  return node_path.isAbsolute(targetPath) ? targetPath : node_path.join(rootDir, targetPath);
1732
1944
  }
@@ -1805,6 +2017,7 @@ async function fetchSkillsFromLock(rootDir, manifest, lockfile, options) {
1805
2017
  for (const [skillName, entry] of Object.entries(lockfile.skills)){
1806
2018
  if ('link' === entry.resolution.type) {
1807
2019
  await materializeLocalSkill(rootDir, skillName, node_path.resolve(rootDir, entry.resolution.path), '/', installDir);
2020
+ if (entry.patch) await applySkillPatch(node_path.join(rootDir, installDir, skillName), node_path.resolve(rootDir, entry.patch.path));
1808
2021
  options?.onProgress?.({
1809
2022
  type: 'added',
1810
2023
  skillName
@@ -1813,6 +2026,7 @@ async function fetchSkillsFromLock(rootDir, manifest, lockfile, options) {
1813
2026
  }
1814
2027
  if ('file' === entry.resolution.type) {
1815
2028
  await materializePackedSkill(rootDir, skillName, node_path.resolve(rootDir, entry.resolution.tarball), entry.resolution.path, installDir);
2029
+ if (entry.patch) await applySkillPatch(node_path.join(rootDir, installDir, skillName), node_path.resolve(rootDir, entry.patch.path));
1816
2030
  options?.onProgress?.({
1817
2031
  type: 'added',
1818
2032
  skillName
@@ -1821,6 +2035,7 @@ async function fetchSkillsFromLock(rootDir, manifest, lockfile, options) {
1821
2035
  }
1822
2036
  if ('git' === entry.resolution.type) {
1823
2037
  await materializeGitSkill(rootDir, skillName, entry.resolution.url, entry.resolution.commit, entry.resolution.path, installDir);
2038
+ if (entry.patch) await applySkillPatch(node_path.join(rootDir, installDir, skillName), node_path.resolve(rootDir, entry.patch.path));
1824
2039
  options?.onProgress?.({
1825
2040
  type: 'added',
1826
2041
  skillName
@@ -1836,13 +2051,14 @@ async function fetchSkillsFromLock(rootDir, manifest, lockfile, options) {
1836
2051
  }
1837
2052
  const tarballPath = await tarballPathPromise;
1838
2053
  await materializePackedSkill(rootDir, skillName, tarballPath, entry.resolution.path, installDir);
2054
+ if (entry.patch) await applySkillPatch(node_path.join(rootDir, installDir, skillName), node_path.resolve(rootDir, entry.patch.path));
1839
2055
  options?.onProgress?.({
1840
2056
  type: 'added',
1841
2057
  skillName
1842
2058
  });
1843
2059
  continue;
1844
2060
  }
1845
- throw new Error(`Unsupported resolution type in 0.1.0 core flow: ${entry.resolution.type}`);
2061
+ throw new Error('Unsupported resolution type in 0.1.0 core flow');
1846
2062
  }
1847
2063
  await writeInstallState(rootDir, installDir, {
1848
2064
  lockDigest,
@@ -1888,7 +2104,7 @@ async function installSkills(rootDir, options) {
1888
2104
  let lockfile;
1889
2105
  if (options?.frozenLockfile) {
1890
2106
  if (!currentLock) throw new Error('Lockfile is required in frozen mode but none was found');
1891
- if (!isLockInSync(manifest, currentLock)) throw new Error('Lockfile is out of sync with manifest. Run install without --frozen-lockfile to update.');
2107
+ if (!await isLockInSync(rootDir, manifest, currentLock)) throw new Error('Lockfile is out of sync with manifest. Run install without --frozen-lockfile to update.');
1892
2108
  lockfile = currentLock;
1893
2109
  for (const skillName of Object.keys(lockfile.skills))options?.onProgress?.({
1894
2110
  type: 'resolved',
@@ -2500,7 +2716,7 @@ async function installCommand(options) {
2500
2716
  filePath: `${options.cwd}/skills-lock.yaml`,
2501
2717
  message: 'Lockfile is required in frozen mode but none was found. Run "spm install" first.'
2502
2718
  });
2503
- if (!isLockInSync(manifest, currentLock)) throw new ManifestError({
2719
+ if (!await isLockInSync(options.cwd, manifest, currentLock)) throw new ManifestError({
2504
2720
  code: codes_ErrorCode.LOCKFILE_OUTDATED,
2505
2721
  filePath: `${options.cwd}/skills-lock.yaml`,
2506
2722
  message: 'Lockfile is out of sync with manifest. Run install without --frozen-lockfile to update.'
@@ -2536,6 +2752,193 @@ async function installCommand(options) {
2536
2752
  throw error;
2537
2753
  }
2538
2754
  }
2755
+ async function extractSkillToDir(rootDir, entry, targetDir) {
2756
+ if ('link' === entry.resolution.type) return void await copyLocalSkillToDir(node_path.resolve(rootDir, entry.resolution.path), '/', targetDir);
2757
+ if ('file' === entry.resolution.type) return void await extractPackedSkillToDir(node_path.resolve(rootDir, entry.resolution.tarball), entry.resolution.path, targetDir);
2758
+ if ('git' === entry.resolution.type) return void await extractGitSkillToDir(entry.resolution.url, entry.resolution.commit, entry.resolution.path, targetDir);
2759
+ if ('npm' === entry.resolution.type) {
2760
+ const tarballPath = await downloadNpmPackageTarball(rootDir, entry.resolution.tarball, entry.resolution.integrity);
2761
+ try {
2762
+ await extractPackedSkillToDir(tarballPath, entry.resolution.path, targetDir);
2763
+ } finally{
2764
+ await cleanupPackedNpmPackage(tarballPath);
2765
+ }
2766
+ return;
2767
+ }
2768
+ throw new Error('Unsupported resolution type in 0.1.0 core flow');
2769
+ }
2770
+ async function ensureEditDirDoesNotExist(editDir) {
2771
+ try {
2772
+ await access(editDir);
2773
+ } catch (error) {
2774
+ if ('ENOENT' === error.code) return;
2775
+ throw convertNodeError(error, {
2776
+ operation: 'access',
2777
+ path: editDir
2778
+ });
2779
+ }
2780
+ throw new FileSystemError({
2781
+ code: codes_ErrorCode.FILE_EXISTS,
2782
+ operation: 'mkdir',
2783
+ path: editDir,
2784
+ message: `Patch edit directory already exists: ${editDir}`
2785
+ });
2786
+ }
2787
+ async function createBaseLock(cwd, manifest, currentLock) {
2788
+ if (currentLock && await isLockInSync(cwd, manifest, currentLock)) return {
2789
+ ...currentLock,
2790
+ skills: {
2791
+ ...currentLock.skills
2792
+ }
2793
+ };
2794
+ return syncSkillsLock(cwd, manifest, currentLock);
2795
+ }
2796
+ function getUnpatchedBaseEntry(entry) {
2797
+ if (!entry.patch) return entry;
2798
+ const { patch: _patch, ...baseEntry } = entry;
2799
+ return baseEntry;
2800
+ }
2801
+ async function resolveEditDir(cwd, skillName, editDir) {
2802
+ if (editDir) return node_path.resolve(cwd, editDir);
2803
+ const sanitizedSkillName = skillName.replace(/[^a-zA-Z0-9._-]+/g, '-');
2804
+ return mkdtemp(node_path.join(tmpdir(), `skills-pm-patch-${sanitizedSkillName}-`));
2805
+ }
2806
+ async function patchCommand(options) {
2807
+ const manifest = await readSkillsManifest(options.cwd);
2808
+ if (!manifest) throw new ManifestError({
2809
+ code: codes_ErrorCode.MANIFEST_NOT_FOUND,
2810
+ filePath: `${options.cwd}/skills.json`,
2811
+ message: 'No skills.json found in the current directory. Run "spm init" to create one.'
2812
+ });
2813
+ if (!(options.skillName in manifest.skills)) throw new SkillError({
2814
+ code: codes_ErrorCode.SKILL_NOT_FOUND,
2815
+ skillName: options.skillName,
2816
+ message: `Unknown skill: ${options.skillName}`
2817
+ });
2818
+ const currentLock = await readSkillsLock(options.cwd);
2819
+ const baseLock = await createBaseLock(options.cwd, manifest, currentLock);
2820
+ const currentEntry = baseLock.skills[options.skillName];
2821
+ if (!currentEntry) throw new SkillError({
2822
+ code: codes_ErrorCode.SKILL_NOT_FOUND,
2823
+ skillName: options.skillName,
2824
+ message: `Skill "${options.skillName}" is missing from the resolved lockfile state`
2825
+ });
2826
+ const editDir = await resolveEditDir(options.cwd, options.skillName, options.editDir);
2827
+ if (options.editDir) await ensureEditDirDoesNotExist(editDir);
2828
+ const baseEntry = getUnpatchedBaseEntry(currentEntry);
2829
+ await extractSkillToDir(options.cwd, baseEntry, editDir);
2830
+ const existingPatchPath = manifest.patchedSkills?.[options.skillName];
2831
+ if (existingPatchPath && !options.ignoreExisting) await applySkillPatch(editDir, node_path.resolve(options.cwd, existingPatchPath));
2832
+ await writePatchEditState(editDir, {
2833
+ version: 1,
2834
+ skillName: options.skillName,
2835
+ originalSpecifier: manifest.skills[options.skillName],
2836
+ baseEntry
2837
+ });
2838
+ console.info(editDir);
2839
+ return {
2840
+ status: 'patched',
2841
+ skillName: options.skillName,
2842
+ editDir,
2843
+ originalSpecifier: manifest.skills[options.skillName]
2844
+ };
2845
+ }
2846
+ async function patchCommit_createBaseLock(cwd, manifest, currentLock) {
2847
+ if (currentLock && await isLockInSync(cwd, manifest, currentLock)) return {
2848
+ ...currentLock,
2849
+ skills: {
2850
+ ...currentLock.skills
2851
+ }
2852
+ };
2853
+ return syncSkillsLock(cwd, manifest, currentLock);
2854
+ }
2855
+ function resolvePatchFilePath(cwd, skillName, existingPatchPath, patchesDir) {
2856
+ if (patchesDir) return node_path.resolve(cwd, patchesDir, `${skillName}.patch`);
2857
+ if (existingPatchPath) return node_path.resolve(cwd, existingPatchPath);
2858
+ return node_path.resolve(cwd, 'patches', `${skillName}.patch`);
2859
+ }
2860
+ async function patchCommitCommand(options) {
2861
+ const manifest = await readSkillsManifest(options.cwd);
2862
+ if (!manifest) throw new ManifestError({
2863
+ code: codes_ErrorCode.MANIFEST_NOT_FOUND,
2864
+ filePath: `${options.cwd}/skills.json`,
2865
+ message: 'No skills.json found in the current directory. Run "spm init" to create one.'
2866
+ });
2867
+ const editDir = node_path.resolve(options.cwd, options.editDir);
2868
+ const editState = await readPatchEditState(editDir);
2869
+ if (!(editState.skillName in manifest.skills)) throw new SkillError({
2870
+ code: codes_ErrorCode.SKILL_NOT_FOUND,
2871
+ skillName: editState.skillName,
2872
+ message: `Unknown skill: ${editState.skillName}`
2873
+ });
2874
+ if (manifest.skills[editState.skillName] !== editState.originalSpecifier) throw new SkillError({
2875
+ code: codes_ErrorCode.VALIDATION_ERROR,
2876
+ skillName: editState.skillName,
2877
+ message: `Skill "${editState.skillName}" changed since "spm patch" created ${editDir}`
2878
+ });
2879
+ const baseDir = await mkdtemp(node_path.join(tmpdir(), `skills-pm-patch-base-${editState.skillName}-`));
2880
+ try {
2881
+ await extractSkillToDir(options.cwd, editState.baseEntry, baseDir);
2882
+ const patchContent = await generateSkillPatch(baseDir, editDir);
2883
+ if (!patchContent.trim()) throw new SkillError({
2884
+ code: codes_ErrorCode.VALIDATION_ERROR,
2885
+ skillName: editState.skillName,
2886
+ message: `No changes found in ${editDir}`
2887
+ });
2888
+ const patchFilePath = resolvePatchFilePath(options.cwd, editState.skillName, manifest.patchedSkills?.[editState.skillName], options.patchesDir);
2889
+ await mkdir(node_path.dirname(patchFilePath), {
2890
+ recursive: true
2891
+ });
2892
+ await writeFile(patchFilePath, patchContent, 'utf8');
2893
+ const relativePatchPath = toPortableRelativePath(options.cwd, patchFilePath);
2894
+ const nextManifest = {
2895
+ ...manifest,
2896
+ patchedSkills: {
2897
+ ...manifest.patchedSkills ?? {},
2898
+ [editState.skillName]: relativePatchPath
2899
+ }
2900
+ };
2901
+ const currentLock = await readSkillsLock(options.cwd);
2902
+ const baseLock = await patchCommit_createBaseLock(options.cwd, manifest, currentLock);
2903
+ const patchedEntry = await attachManifestPatchToEntry(options.cwd, nextManifest, editState.skillName, editState.baseEntry);
2904
+ const nextLock = {
2905
+ ...baseLock,
2906
+ installDir: nextManifest.installDir ?? '.agents/skills',
2907
+ linkTargets: nextManifest.linkTargets ?? [],
2908
+ skills: {
2909
+ ...baseLock.skills,
2910
+ [editState.skillName]: patchedEntry
2911
+ }
2912
+ };
2913
+ const runtimeLock = await withBundledSelfSkillLock(options.cwd, nextManifest, nextLock);
2914
+ await fetchSkillsFromLock(options.cwd, nextManifest, runtimeLock);
2915
+ await linkSkillsFromLock(options.cwd, nextManifest, runtimeLock);
2916
+ await writeSkillsManifest(options.cwd, nextManifest);
2917
+ await writeSkillsLock(options.cwd, nextLock);
2918
+ console.info(relativePatchPath);
2919
+ return {
2920
+ status: 'patched',
2921
+ skillName: editState.skillName,
2922
+ patchFile: patchFilePath
2923
+ };
2924
+ } finally{
2925
+ await rm(baseDir, {
2926
+ recursive: true,
2927
+ force: true
2928
+ }).catch(()=>{});
2929
+ }
2930
+ }
2931
+ function normalizeValue(value) {
2932
+ if (Array.isArray(value)) return value.map((item)=>normalizeValue(item));
2933
+ if (value && 'object' == typeof value) return Object.fromEntries(Object.entries(value).sort(([leftKey], [rightKey])=>leftKey.localeCompare(rightKey)).map(([key, nestedValue])=>[
2934
+ key,
2935
+ normalizeValue(nestedValue)
2936
+ ]));
2937
+ return value;
2938
+ }
2939
+ function stableStringify(value) {
2940
+ return JSON.stringify(normalizeValue(value));
2941
+ }
2539
2942
  function createEmptyResult() {
2540
2943
  return {
2541
2944
  status: 'skipped',
@@ -2545,7 +2948,7 @@ function createEmptyResult() {
2545
2948
  failed: []
2546
2949
  };
2547
2950
  }
2548
- function createBaseLock(_cwd, currentLock) {
2951
+ function update_createBaseLock(_cwd, currentLock) {
2549
2952
  if (currentLock) return {
2550
2953
  ...currentLock,
2551
2954
  skills: {
@@ -2574,7 +2977,7 @@ async function updateCommand(options) {
2574
2977
  message: `Unknown skill: ${skillName}`
2575
2978
  });
2576
2979
  const result = createEmptyResult();
2577
- const candidateLock = createBaseLock(options.cwd, currentLock);
2980
+ const candidateLock = update_createBaseLock(options.cwd, currentLock);
2578
2981
  candidateLock.installDir = manifest.installDir ?? '.agents/skills';
2579
2982
  candidateLock.linkTargets = manifest.linkTargets ?? [];
2580
2983
  for (const skillName of targetSkills){
@@ -2589,20 +2992,13 @@ async function updateCommand(options) {
2589
2992
  continue;
2590
2993
  }
2591
2994
  const { entry } = await resolveLockEntry(options.cwd, specifier);
2995
+ const nextEntry = await attachManifestPatchToEntry(options.cwd, manifest, skillName, entry);
2592
2996
  const previous = currentLock?.skills[skillName];
2593
- if (previous?.resolution.type === 'git' && 'git' === entry.resolution.type && previous.specifier === entry.specifier && previous.resolution.url === entry.resolution.url && previous.resolution.commit === entry.resolution.commit && previous.resolution.path === entry.resolution.path) {
2997
+ if (previous && stableStringify(previous) === stableStringify(nextEntry)) {
2594
2998
  result.unchanged.push(skillName);
2595
2999
  continue;
2596
3000
  }
2597
- if (previous?.resolution.type === 'npm' && 'npm' === entry.resolution.type && previous.specifier === entry.specifier && previous.resolution.packageName === entry.resolution.packageName && previous.resolution.version === entry.resolution.version && previous.resolution.path === entry.resolution.path && previous.resolution.tarball === entry.resolution.tarball && previous.resolution.integrity === entry.resolution.integrity && previous.resolution.registry === entry.resolution.registry) {
2598
- result.unchanged.push(skillName);
2599
- continue;
2600
- }
2601
- if (previous?.resolution.type === 'file' && 'file' === entry.resolution.type && previous.specifier === entry.specifier && previous.digest === entry.digest) {
2602
- result.unchanged.push(skillName);
2603
- continue;
2604
- }
2605
- candidateLock.skills[skillName] = entry;
3001
+ candidateLock.skills[skillName] = nextEntry;
2606
3002
  result.updated.push(skillName);
2607
3003
  } catch (error) {
2608
3004
  result.failed.push({
@@ -2626,6 +3022,8 @@ function createHandlers(overrides) {
2626
3022
  return {
2627
3023
  addCommand: addCommand,
2628
3024
  installCommand: installCommand,
3025
+ patchCommitCommand: patchCommitCommand,
3026
+ patchCommand: patchCommand,
2629
3027
  updateCommand: updateCommand,
2630
3028
  initCommand: initCommand,
2631
3029
  ...overrides
@@ -2661,6 +3059,17 @@ async function runCli(argv, context = {}) {
2661
3059
  cwd,
2662
3060
  frozenLockfile: options.frozenLockfile
2663
3061
  }));
3062
+ cli.command('patch <skill>').option('--edit-dir <dir>', 'Directory to extract the editable skill into').option('--ignore-existing', 'Ignore an existing committed patch while preparing the edit dir').action(async (skill, options)=>handlers.patchCommand({
3063
+ cwd,
3064
+ skillName: skill,
3065
+ editDir: options.editDir,
3066
+ ignoreExisting: options.ignoreExisting
3067
+ }));
3068
+ cli.command('patch-commit <editDir>').option('--patches-dir <dir>', 'Directory to save the generated patch file into').action(async (editDir, options)=>handlers.patchCommitCommand({
3069
+ cwd,
3070
+ editDir,
3071
+ patchesDir: options.patchesDir
3072
+ }));
2664
3073
  cli.command('update [...skills]').action(async (skills = [])=>handlers.updateCommand({
2665
3074
  cwd,
2666
3075
  skills: skills.length > 0 ? skills : void 0
@@ -2696,4 +3105,4 @@ async function runCli(argv, context = {}) {
2696
3105
  throw error;
2697
3106
  }
2698
3107
  }
2699
- export { FileSystemError, GitError, ManifestError, NetworkError, ParseError, SkillError, SpmError, addCommand, cloneAndDiscover, codes_ErrorCode as ErrorCode, convertNodeError, createInstallProgressReporter, discoverSkillsInDir, expandSkillsManifest, fetchSkillsFromLock, formatErrorForDisplay, getExitCode, initCommand, installCommand, installSkills, installStageHooks, isLockInSync, isSpmError, linkSkillsFromLock, listRepoSkills, normalizeSkillsManifest, normalizeSpecifier, parseGitHubUrl, parseOwnerRepo, parseSpecifier, readSkillsLock, readSkillsManifest, resolveLockEntry, runCli, updateCommand, writeSkillsLock, writeSkillsManifest };
3108
+ export { FileSystemError, GitError, ManifestError, NetworkError, ParseError, SkillError, SpmError, addCommand, cloneAndDiscover, codes_ErrorCode as ErrorCode, convertNodeError, createInstallProgressReporter, discoverSkillsInDir, expandSkillsManifest, fetchSkillsFromLock, formatErrorForDisplay, getExitCode, initCommand, installCommand, installSkills, installStageHooks, isLockInSync, isSpmError, linkSkillsFromLock, listRepoSkills, normalizeSkillsManifest, normalizeSpecifier, parseGitHubUrl, parseOwnerRepo, parseSpecifier, patchCommand, patchCommitCommand, readSkillsLock, readSkillsManifest, resolveLockEntry, runCli, updateCommand, writeSkillsLock, writeSkillsManifest };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "skills-package-manager",
3
- "version": "0.7.0",
3
+ "version": "0.8.0",
4
4
  "type": "module",
5
5
  "repository": {
6
6
  "type": "git",
@@ -20,10 +20,12 @@
20
20
  "skills.schema.json"
21
21
  ],
22
22
  "devDependencies": {
23
+ "@microsoft/api-extractor": "^7.58.5",
23
24
  "@clack/prompts": "^1.1.0",
24
25
  "cac": "^7.0.0",
25
26
  "picocolors": "^1.1.1",
26
27
  "semver": "^7.7.2",
28
+ "@types/semver": "^7.7.1",
27
29
  "tar": "^7.4.3",
28
30
  "yaml": "^2.8.1",
29
31
  "zod": "^4.3.6",
@@ -33,6 +33,16 @@
33
33
  "additionalProperties": {
34
34
  "type": "string"
35
35
  }
36
+ },
37
+ "patchedSkills": {
38
+ "description": "Map of skill names to their patch file paths",
39
+ "type": "object",
40
+ "propertyNames": {
41
+ "type": "string"
42
+ },
43
+ "additionalProperties": {
44
+ "type": "string"
45
+ }
36
46
  }
37
47
  },
38
48
  "required": [