dryai 2.0.0 → 2.1.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
@@ -119,8 +119,10 @@ dryai install
119
119
  dryai skills list List local skills
120
120
  dryai skills add [options] <repo> Add managed skills from a remote repository
121
121
  dryai skills remove <name> Remove a managed skill
122
- dryai skills update <name> Update a managed skill from its tracked source
123
- dryai skills update-all Update all managed skills from their tracked sources
122
+ dryai skills rehash <name> Refresh stored file hashes for one managed skill
123
+ dryai skills rehash-all Refresh stored file hashes for all managed skills
124
+ dryai skills update [options] <name> Update a managed skill from its tracked source
125
+ dryai skills update-all [options] Update all managed skills from their tracked sources
124
126
  ```
125
127
 
126
128
  `<repo>` may be a full git remote URL or a GitHub `owner/repo` shorthand such as `anthropics/skills`.
@@ -152,13 +154,32 @@ The lockfile records:
152
154
  - the source path within that repository
153
155
  - the requested git ref, when one was provided
154
156
  - the resolved commit that was imported
157
+ - the last installed content hash for each file in the managed skill directory
155
158
 
156
159
  `skills update` and `skills update-all` re-fetch the tracked repository snapshot and replace the local copied skill directory.
157
160
 
161
+ Before replacing a managed skill, `dryai` compares the current local files against the hashes stored in `skills.lock.json`. If any file was added, removed, or edited locally, the update is skipped and a warning is printed so you do not lose your customizations by accident.
162
+
163
+ For skills imported before hash tracking existed, use `rehash` to store hashes from the current local directory without fetching from the remote source:
164
+
165
+ ```sh
166
+ dryai skills rehash review-helper
167
+ dryai skills rehash-all
168
+ ```
169
+
170
+ Use `--force` to intentionally overwrite local edits:
171
+
172
+ ```sh
173
+ dryai skills update review-helper --force
174
+ dryai skills update-all --force
175
+ ```
176
+
158
177
  `skills remove` deletes the local copied skill directory and removes its lockfile entry.
159
178
 
160
179
  `skills list` reports local skill directories, annotating managed entries from the lockfile and flagging managed entries whose local directory is missing.
161
180
 
181
+ `skills rehash` updates the stored file hashes for one managed skill using its current local contents. `skills rehash-all` does the same for every managed skill and skips any managed entry whose local directory is missing.
182
+
162
183
  ## Development
163
184
 
164
185
  For development, use `pnpm dev` to rebuild into `dest/` on change and `pnpm dev:dryai --test install` to run the built CLI.
@@ -1,5 +1,5 @@
1
1
  import fs from 'fs-extra';
2
- import { cloneRemoteRepo, createImportedSkillRecord, ensureSkillsLockfile, ensureSkillsRoot, findManagedSkill, formatManagedSkillSummary, getManagedSkillDirectory, loadSkillsLockfile, normalizeRemoteRepo, replaceManagedSkillDirectory, resolveManagedSkillImportPath, resolveSkillSourceDir, saveSkillsLockfile, timestampNow, upsertManagedSkill, } from '../../lib/skills.js';
2
+ import { cloneRemoteRepo, computeDirectoryHashes, createImportedSkillRecord, ensureSkillsLockfile, ensureSkillsRoot, findManagedSkill, formatManagedSkillSummary, getManagedSkillDirectory, loadSkillsLockfile, normalizeRemoteRepo, replaceManagedSkillDirectory, resolveManagedSkillImportPath, resolveSkillSourceDir, saveSkillsLockfile, timestampNow, upsertManagedSkill, } from '../../lib/skills.js';
3
3
  /**
4
4
  * Normalizes and de-duplicates requested skill names while preserving their input order.
5
5
  */
@@ -69,8 +69,10 @@ export async function runSkillsAddCommand(context, input) {
69
69
  targetDir,
70
70
  sourceDir,
71
71
  });
72
+ const installedFiles = await computeDirectoryHashes(targetDir);
72
73
  const importedSkill = createImportedSkillRecord({
73
74
  commit: checkout.commit,
75
+ files: installedFiles,
74
76
  importedAt: timestampNow(),
75
77
  name: skillName,
76
78
  path: importedSkillPath,
@@ -5,6 +5,8 @@ import { nonEmptyOptionStringSchema, parseOptionsObject, parseOptionValue, } fro
5
5
  import {} from '../../lib/context.js';
6
6
  import { runSkillsAddCommand } from './add.js';
7
7
  import { runSkillsListCommand } from './list.js';
8
+ import { runSkillsRehashAllCommand } from './rehash-all.js';
9
+ import { runSkillsRehashCommand } from './rehash.js';
8
10
  import { runSkillsRemoveCommand } from './remove.js';
9
11
  import { runSkillsUpdateAllCommand } from './update-all.js';
10
12
  import { runSkillsUpdateCommand } from './update.js';
@@ -14,6 +16,9 @@ const skillsImportOptionsSchema = z.object({
14
16
  pin: z.boolean().optional().default(false),
15
17
  ref: nonEmptyOptionStringSchema.optional(),
16
18
  });
19
+ const skillsUpdateOptionsSchema = z.object({
20
+ force: z.boolean().optional().default(false),
21
+ });
17
22
  /**
18
23
  * Registers the managed skills command tree on the parent CLI program.
19
24
  */
@@ -30,6 +35,7 @@ export function addSkillsCommand(input) {
30
35
  ${commandName} list
31
36
  ${commandName} add anthropics/skills --skill skill-creator
32
37
  ${commandName} add vercel-labs/agent-skills --skill pr-review commit
38
+ ${commandName} rehash skill-creator
33
39
  ${commandName} update skill-creator
34
40
  `)
35
41
  .action(() => {
@@ -74,17 +80,46 @@ export function addSkillsCommand(input) {
74
80
  .action(async (skillName) => {
75
81
  await runSkillsRemoveCommand(resolveContext(), { skillName });
76
82
  });
83
+ skills
84
+ .command('rehash <name>')
85
+ .description('Refresh stored file hashes for one managed skill')
86
+ .action(async (skillName) => {
87
+ await runSkillsRehashCommand(resolveContext(), { skillName });
88
+ });
89
+ skills
90
+ .command('rehash-all')
91
+ .description('Refresh stored file hashes for all managed skills')
92
+ .action(async () => {
93
+ await runSkillsRehashAllCommand(resolveContext());
94
+ });
77
95
  skills
78
96
  .command('update <name>')
79
97
  .description('Update a managed skill from its tracked source')
80
- .action(async (skillName) => {
81
- await runSkillsUpdateCommand(resolveContext(), { skillName });
98
+ .option('--force', 'Overwrite local skill edits with the fetched remote copy')
99
+ .action(async (skillName, options) => {
100
+ const parsedOptions = parseOptionsObject({
101
+ schema: skillsUpdateOptionsSchema,
102
+ options,
103
+ optionsLabel: 'skills update options',
104
+ });
105
+ await runSkillsUpdateCommand(resolveContext(), {
106
+ force: parsedOptions.force,
107
+ skillName,
108
+ });
82
109
  });
83
110
  skills
84
111
  .command('update-all')
85
112
  .description('Update all managed skills from their tracked sources')
86
- .action(async () => {
87
- await runSkillsUpdateAllCommand(resolveContext());
113
+ .option('--force', 'Overwrite local skill edits with the fetched remote copy')
114
+ .action(async (options) => {
115
+ const parsedOptions = parseOptionsObject({
116
+ schema: skillsUpdateOptionsSchema,
117
+ options,
118
+ optionsLabel: 'skills update-all options',
119
+ });
120
+ await runSkillsUpdateAllCommand(resolveContext(), {
121
+ force: parsedOptions.force,
122
+ });
88
123
  });
89
124
  return skills;
90
125
  }
@@ -0,0 +1,6 @@
1
+ import type { AgentsContext } from '../../lib/context.js';
2
+ /**
3
+ * Refreshes the stored file hashes for every managed skill using current local directory contents.
4
+ */
5
+ export declare function runSkillsRehashAllCommand(context: AgentsContext): Promise<void>;
6
+ //# sourceMappingURL=rehash-all.d.ts.map
@@ -0,0 +1,42 @@
1
+ import fs from 'fs-extra';
2
+ import { computeDirectoryHashes, createUpdatedSkillRecord, formatManagedSkillSummary, getManagedSkillDirectory, loadSkillsLockfile, saveSkillsLockfile, timestampNow, upsertManagedSkill, } from '../../lib/skills.js';
3
+ /**
4
+ * Refreshes the stored file hashes for every managed skill using current local directory contents.
5
+ */
6
+ export async function runSkillsRehashAllCommand(context) {
7
+ let lockfile = await loadSkillsLockfile(context);
8
+ if (lockfile.skills.length === 0) {
9
+ console.log('No managed skills to rehash.');
10
+ return;
11
+ }
12
+ const rehashedLines = [];
13
+ const skippedLines = [];
14
+ for (const managedSkill of lockfile.skills) {
15
+ const targetDir = getManagedSkillDirectory(context, {
16
+ skillName: managedSkill.name,
17
+ });
18
+ if (!(await fs.pathExists(targetDir))) {
19
+ skippedLines.push(`- ${formatManagedSkillSummary(managedSkill)} missing-local-directory`);
20
+ continue;
21
+ }
22
+ const installedFiles = await computeDirectoryHashes(targetDir);
23
+ const updatedSkill = createUpdatedSkillRecord({
24
+ commit: managedSkill.commit,
25
+ existingSkill: managedSkill,
26
+ files: installedFiles,
27
+ updatedAt: timestampNow(),
28
+ });
29
+ lockfile = upsertManagedSkill(lockfile, { updatedSkill });
30
+ rehashedLines.push(`- ${formatManagedSkillSummary(updatedSkill)}`);
31
+ }
32
+ await saveSkillsLockfile(context, { lockfile });
33
+ if (rehashedLines.length > 0) {
34
+ console.log(`Rehashed ${rehashedLines.length} managed skills:\n${rehashedLines.join('\n')}`);
35
+ }
36
+ else {
37
+ console.log('No managed skills were rehashed.');
38
+ }
39
+ if (skippedLines.length > 0) {
40
+ console.warn(`Skipped ${skippedLines.length} managed skills because the local directory is missing:\n${skippedLines.join('\n')}`);
41
+ }
42
+ }
@@ -0,0 +1,8 @@
1
+ import type { AgentsContext } from '../../lib/context.js';
2
+ /**
3
+ * Refreshes the stored file hashes for one managed skill using the current local directory contents.
4
+ */
5
+ export declare function runSkillsRehashCommand(context: AgentsContext, input: {
6
+ skillName: string;
7
+ }): Promise<void>;
8
+ //# sourceMappingURL=rehash.d.ts.map
@@ -0,0 +1,28 @@
1
+ import fs from 'fs-extra';
2
+ import { computeDirectoryHashes, createUpdatedSkillRecord, findManagedSkill, formatManagedSkillSummary, getManagedSkillDirectory, loadSkillsLockfile, saveSkillsLockfile, timestampNow, upsertManagedSkill, } from '../../lib/skills.js';
3
+ /**
4
+ * Refreshes the stored file hashes for one managed skill using the current local directory contents.
5
+ */
6
+ export async function runSkillsRehashCommand(context, input) {
7
+ const { skillName } = input;
8
+ const lockfile = await loadSkillsLockfile(context);
9
+ const managedSkill = findManagedSkill(lockfile, { name: skillName });
10
+ if (!managedSkill) {
11
+ throw new Error(`Managed skill not found: ${skillName}`);
12
+ }
13
+ const targetDir = getManagedSkillDirectory(context, { skillName });
14
+ if (!(await fs.pathExists(targetDir))) {
15
+ throw new Error(`Managed skill directory not found: ${targetDir}`);
16
+ }
17
+ const installedFiles = await computeDirectoryHashes(targetDir);
18
+ const updatedSkill = createUpdatedSkillRecord({
19
+ commit: managedSkill.commit,
20
+ existingSkill: managedSkill,
21
+ files: installedFiles,
22
+ updatedAt: timestampNow(),
23
+ });
24
+ await saveSkillsLockfile(context, {
25
+ lockfile: upsertManagedSkill(lockfile, { updatedSkill }),
26
+ });
27
+ console.log(`Rehashed ${formatManagedSkillSummary(updatedSkill)}`);
28
+ }
@@ -2,5 +2,7 @@ import type { AgentsContext } from '../../lib/context.js';
2
2
  /**
3
3
  * Updates every managed skill from its tracked remote source and saves the refreshed lockfile.
4
4
  */
5
- export declare function runSkillsUpdateAllCommand(context: AgentsContext): Promise<void>;
5
+ export declare function runSkillsUpdateAllCommand(context: AgentsContext, input: {
6
+ force: boolean;
7
+ }): Promise<void>;
6
8
  //# sourceMappingURL=update-all.d.ts.map
@@ -1,15 +1,27 @@
1
- import { createUpdatedSkillRecord, fetchRemoteSkillSnapshot, formatManagedSkillSummary, getManagedSkillDirectory, loadSkillsLockfile, replaceManagedSkillDirectory, saveSkillsLockfile, timestampNow, upsertManagedSkill, } from '../../lib/skills.js';
1
+ import { computeDirectoryHashes, createUpdatedSkillRecord, detectLocalSkillEdits, fetchRemoteSkillSnapshot, formatManagedSkillSummary, getManagedSkillDirectory, loadSkillsLockfile, replaceManagedSkillDirectory, saveSkillsLockfile, timestampNow, upsertManagedSkill, } from '../../lib/skills.js';
2
2
  /**
3
3
  * Updates every managed skill from its tracked remote source and saves the refreshed lockfile.
4
4
  */
5
- export async function runSkillsUpdateAllCommand(context) {
5
+ export async function runSkillsUpdateAllCommand(context, input) {
6
6
  let lockfile = await loadSkillsLockfile(context);
7
7
  if (lockfile.skills.length === 0) {
8
8
  console.log('No managed skills to update.');
9
9
  return;
10
10
  }
11
11
  const updatedLines = [];
12
+ const skippedLines = [];
12
13
  for (const managedSkill of lockfile.skills) {
14
+ const targetDir = getManagedSkillDirectory(context, {
15
+ skillName: managedSkill.name,
16
+ });
17
+ const localEditState = await detectLocalSkillEdits({
18
+ skillDir: targetDir,
19
+ storedFiles: managedSkill.files,
20
+ });
21
+ if (localEditState.modified && !input.force) {
22
+ skippedLines.push(`- ${managedSkill.name} local edits detected in ${localEditState.changedFiles.join(', ')}`);
23
+ continue;
24
+ }
13
25
  const snapshot = await fetchRemoteSkillSnapshot({
14
26
  ref: managedSkill.ref,
15
27
  repo: managedSkill.repo,
@@ -17,23 +29,31 @@ export async function runSkillsUpdateAllCommand(context) {
17
29
  });
18
30
  try {
19
31
  await replaceManagedSkillDirectory({
20
- targetDir: getManagedSkillDirectory(context, {
21
- skillName: managedSkill.name,
22
- }),
32
+ targetDir,
23
33
  sourceDir: snapshot.sourceDir,
24
34
  });
35
+ const installedFiles = await computeDirectoryHashes(targetDir);
36
+ const updatedSkill = createUpdatedSkillRecord({
37
+ commit: snapshot.commit,
38
+ existingSkill: managedSkill,
39
+ files: installedFiles,
40
+ updatedAt: timestampNow(),
41
+ });
42
+ lockfile = upsertManagedSkill(lockfile, { updatedSkill });
43
+ updatedLines.push(`- ${formatManagedSkillSummary(updatedSkill)}`);
25
44
  }
26
45
  finally {
27
46
  await snapshot.cleanup();
28
47
  }
29
- const updatedSkill = createUpdatedSkillRecord({
30
- commit: snapshot.commit,
31
- existingSkill: managedSkill,
32
- updatedAt: timestampNow(),
33
- });
34
- lockfile = upsertManagedSkill(lockfile, { updatedSkill });
35
- updatedLines.push(`- ${formatManagedSkillSummary(updatedSkill)}`);
36
48
  }
37
49
  await saveSkillsLockfile(context, { lockfile });
38
- console.log(`Updated ${updatedLines.length} managed skills:\n${updatedLines.join('\n')}`);
50
+ if (updatedLines.length > 0) {
51
+ console.log(`Updated ${updatedLines.length} managed skills:\n${updatedLines.join('\n')}`);
52
+ }
53
+ else {
54
+ console.log('No managed skills were updated.');
55
+ }
56
+ if (skippedLines.length > 0) {
57
+ console.warn(`Skipped ${skippedLines.length} managed skills due to local edits. Re-run with --force to overwrite local changes:\n${skippedLines.join('\n')}`);
58
+ }
39
59
  }
@@ -3,6 +3,7 @@ import type { AgentsContext } from '../../lib/context.js';
3
3
  * Updates one managed skill from its tracked remote source and refreshes the lockfile.
4
4
  */
5
5
  export declare function runSkillsUpdateCommand(context: AgentsContext, input: {
6
+ force: boolean;
6
7
  skillName: string;
7
8
  }): Promise<void>;
8
9
  //# sourceMappingURL=update.d.ts.map
@@ -1,14 +1,23 @@
1
- import { createUpdatedSkillRecord, fetchRemoteSkillSnapshot, findManagedSkill, formatManagedSkillSummary, getManagedSkillDirectory, loadSkillsLockfile, replaceManagedSkillDirectory, saveSkillsLockfile, timestampNow, upsertManagedSkill, } from '../../lib/skills.js';
1
+ import { computeDirectoryHashes, createUpdatedSkillRecord, detectLocalSkillEdits, fetchRemoteSkillSnapshot, findManagedSkill, formatManagedSkillSummary, getManagedSkillDirectory, loadSkillsLockfile, replaceManagedSkillDirectory, saveSkillsLockfile, timestampNow, upsertManagedSkill, } from '../../lib/skills.js';
2
2
  /**
3
3
  * Updates one managed skill from its tracked remote source and refreshes the lockfile.
4
4
  */
5
5
  export async function runSkillsUpdateCommand(context, input) {
6
- const { skillName } = input;
6
+ const { force, skillName } = input;
7
7
  const lockfile = await loadSkillsLockfile(context);
8
8
  const managedSkill = findManagedSkill(lockfile, { name: skillName });
9
9
  if (!managedSkill) {
10
10
  throw new Error(`Managed skill not found: ${skillName}`);
11
11
  }
12
+ const targetDir = getManagedSkillDirectory(context, { skillName });
13
+ const localEditState = await detectLocalSkillEdits({
14
+ skillDir: targetDir,
15
+ storedFiles: managedSkill.files,
16
+ });
17
+ if (localEditState.modified && !force) {
18
+ console.warn(`Skipped ${skillName} because local edits were detected in: ${localEditState.changedFiles.join(', ')}. Re-run with --force to overwrite local changes.`);
19
+ return;
20
+ }
12
21
  const snapshot = await fetchRemoteSkillSnapshot({
13
22
  ref: managedSkill.ref,
14
23
  repo: managedSkill.repo,
@@ -16,20 +25,22 @@ export async function runSkillsUpdateCommand(context, input) {
16
25
  });
17
26
  try {
18
27
  await replaceManagedSkillDirectory({
19
- targetDir: getManagedSkillDirectory(context, { skillName }),
28
+ targetDir,
20
29
  sourceDir: snapshot.sourceDir,
21
30
  });
31
+ const installedFiles = await computeDirectoryHashes(targetDir);
32
+ const updatedSkill = createUpdatedSkillRecord({
33
+ commit: snapshot.commit,
34
+ existingSkill: managedSkill,
35
+ files: installedFiles,
36
+ updatedAt: timestampNow(),
37
+ });
38
+ await saveSkillsLockfile(context, {
39
+ lockfile: upsertManagedSkill(lockfile, { updatedSkill }),
40
+ });
41
+ console.log(`Updated ${formatManagedSkillSummary(updatedSkill)}`);
22
42
  }
23
43
  finally {
24
44
  await snapshot.cleanup();
25
45
  }
26
- const updatedSkill = createUpdatedSkillRecord({
27
- commit: snapshot.commit,
28
- existingSkill: managedSkill,
29
- updatedAt: timestampNow(),
30
- });
31
- await saveSkillsLockfile(context, {
32
- lockfile: upsertManagedSkill(lockfile, { updatedSkill }),
33
- });
34
- console.log(`Updated ${formatManagedSkillSummary(updatedSkill)}`);
35
46
  }
@@ -1,11 +1,13 @@
1
1
  import { z } from 'zod';
2
2
  import type { AgentsContext } from './context.js';
3
+ declare const managedSkillFilesSchema: z.ZodRecord<z.ZodString, z.ZodString>;
3
4
  declare const skillLockEntrySchema: z.ZodObject<{
4
5
  name: z.ZodString;
5
6
  repo: z.ZodString;
6
7
  path: z.ZodString;
7
8
  ref: z.ZodOptional<z.ZodString>;
8
9
  commit: z.ZodString;
10
+ files: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodString>>;
9
11
  importedAt: z.ZodString;
10
12
  updatedAt: z.ZodString;
11
13
  }, "strip", z.ZodTypeAny, {
@@ -16,6 +18,7 @@ declare const skillLockEntrySchema: z.ZodObject<{
16
18
  importedAt: string;
17
19
  updatedAt: string;
18
20
  ref?: string | undefined;
21
+ files?: Record<string, string> | undefined;
19
22
  }, {
20
23
  name: string;
21
24
  path: string;
@@ -24,6 +27,7 @@ declare const skillLockEntrySchema: z.ZodObject<{
24
27
  importedAt: string;
25
28
  updatedAt: string;
26
29
  ref?: string | undefined;
30
+ files?: Record<string, string> | undefined;
27
31
  }>;
28
32
  declare const skillsLockfileSchema: z.ZodEffects<z.ZodObject<{
29
33
  version: z.ZodLiteral<1>;
@@ -33,6 +37,7 @@ declare const skillsLockfileSchema: z.ZodEffects<z.ZodObject<{
33
37
  path: z.ZodString;
34
38
  ref: z.ZodOptional<z.ZodString>;
35
39
  commit: z.ZodString;
40
+ files: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodString>>;
36
41
  importedAt: z.ZodString;
37
42
  updatedAt: z.ZodString;
38
43
  }, "strip", z.ZodTypeAny, {
@@ -43,6 +48,7 @@ declare const skillsLockfileSchema: z.ZodEffects<z.ZodObject<{
43
48
  importedAt: string;
44
49
  updatedAt: string;
45
50
  ref?: string | undefined;
51
+ files?: Record<string, string> | undefined;
46
52
  }, {
47
53
  name: string;
48
54
  path: string;
@@ -51,6 +57,7 @@ declare const skillsLockfileSchema: z.ZodEffects<z.ZodObject<{
51
57
  importedAt: string;
52
58
  updatedAt: string;
53
59
  ref?: string | undefined;
60
+ files?: Record<string, string> | undefined;
54
61
  }>, "many">;
55
62
  }, "strip", z.ZodTypeAny, {
56
63
  skills: {
@@ -61,6 +68,7 @@ declare const skillsLockfileSchema: z.ZodEffects<z.ZodObject<{
61
68
  importedAt: string;
62
69
  updatedAt: string;
63
70
  ref?: string | undefined;
71
+ files?: Record<string, string> | undefined;
64
72
  }[];
65
73
  version: 1;
66
74
  }, {
@@ -72,6 +80,7 @@ declare const skillsLockfileSchema: z.ZodEffects<z.ZodObject<{
72
80
  importedAt: string;
73
81
  updatedAt: string;
74
82
  ref?: string | undefined;
83
+ files?: Record<string, string> | undefined;
75
84
  }[];
76
85
  version: 1;
77
86
  }>, {
@@ -83,6 +92,7 @@ declare const skillsLockfileSchema: z.ZodEffects<z.ZodObject<{
83
92
  importedAt: string;
84
93
  updatedAt: string;
85
94
  ref?: string | undefined;
95
+ files?: Record<string, string> | undefined;
86
96
  }[];
87
97
  version: 1;
88
98
  }, {
@@ -94,11 +104,13 @@ declare const skillsLockfileSchema: z.ZodEffects<z.ZodObject<{
94
104
  importedAt: string;
95
105
  updatedAt: string;
96
106
  ref?: string | undefined;
107
+ files?: Record<string, string> | undefined;
97
108
  }[];
98
109
  version: 1;
99
110
  }>;
100
111
  export type ManagedSkill = z.infer<typeof skillLockEntrySchema>;
101
112
  export type SkillsLockfile = z.infer<typeof skillsLockfileSchema>;
113
+ export type ManagedSkillFiles = z.infer<typeof managedSkillFilesSchema>;
102
114
  export type RemoteSkillSnapshot = {
103
115
  cleanup: () => Promise<void>;
104
116
  commit: string;
@@ -142,19 +154,41 @@ export declare function resolveManagedSkillImportPath({ skillName, }: {
142
154
  skillName: string;
143
155
  }): string;
144
156
  export declare function normalizeImportedSkillPath(skillPath: string | undefined): string;
157
+ /**
158
+ * Creates the lockfile record for a newly imported managed skill.
159
+ */
145
160
  export declare function createImportedSkillRecord(input: {
146
161
  commit: string;
162
+ files: ManagedSkillFiles;
147
163
  importedAt: string;
148
164
  name: string;
149
165
  path: string;
150
166
  ref: string | undefined;
151
167
  repo: string;
152
168
  }): ManagedSkill;
169
+ /**
170
+ * Creates the next lockfile record for a managed skill after its local contents have been refreshed.
171
+ */
153
172
  export declare function createUpdatedSkillRecord(input: {
154
173
  commit: string;
155
174
  existingSkill: ManagedSkill;
175
+ files: ManagedSkillFiles;
156
176
  updatedAt: string;
157
177
  }): ManagedSkill;
178
+ /**
179
+ * Computes stable SHA-256 hashes for every file within a managed skill directory.
180
+ */
181
+ export declare function computeDirectoryHashes(directoryPath: string): Promise<ManagedSkillFiles>;
182
+ /**
183
+ * Detects whether a managed skill directory has local content changes relative to the lockfile snapshot.
184
+ */
185
+ export declare function detectLocalSkillEdits(input: {
186
+ skillDir: string;
187
+ storedFiles: ManagedSkillFiles | undefined;
188
+ }): Promise<{
189
+ changedFiles: string[];
190
+ modified: boolean;
191
+ }>;
158
192
  /**
159
193
  * Clones a remote repository into a temporary checkout and resolves the fetched commit.
160
194
  */
@@ -1,14 +1,17 @@
1
1
  import fs from 'fs-extra';
2
+ import { createHash } from 'node:crypto';
2
3
  import os from 'node:os';
3
4
  import path from 'node:path';
4
5
  import { simpleGit } from 'simple-git';
5
6
  import { z } from 'zod';
7
+ const managedSkillFilesSchema = z.record(z.string(), z.string());
6
8
  const skillLockEntrySchema = z.object({
7
9
  name: z.string().min(1),
8
10
  repo: z.string().min(1),
9
11
  path: z.string().min(1),
10
12
  ref: z.string().min(1).optional(),
11
13
  commit: z.string().min(1),
14
+ files: managedSkillFilesSchema.optional(),
12
15
  importedAt: z.string().datetime({ offset: true }),
13
16
  updatedAt: z.string().datetime({ offset: true }),
14
17
  });
@@ -140,9 +143,13 @@ export function normalizeImportedSkillPath(skillPath) {
140
143
  const normalizedPath = skillPath && skillPath.length > 0 ? skillPath : '.';
141
144
  return path.normalize(normalizedPath);
142
145
  }
146
+ /**
147
+ * Creates the lockfile record for a newly imported managed skill.
148
+ */
143
149
  export function createImportedSkillRecord(input) {
144
150
  return {
145
151
  commit: input.commit,
152
+ files: input.files,
146
153
  importedAt: input.importedAt,
147
154
  name: input.name,
148
155
  path: input.path,
@@ -151,13 +158,56 @@ export function createImportedSkillRecord(input) {
151
158
  updatedAt: input.importedAt,
152
159
  };
153
160
  }
161
+ /**
162
+ * Creates the next lockfile record for a managed skill after its local contents have been refreshed.
163
+ */
154
164
  export function createUpdatedSkillRecord(input) {
155
165
  return {
156
166
  ...input.existingSkill,
157
167
  commit: input.commit,
168
+ files: input.files,
158
169
  updatedAt: input.updatedAt,
159
170
  };
160
171
  }
172
+ /**
173
+ * Computes stable SHA-256 hashes for every file within a managed skill directory.
174
+ */
175
+ export async function computeDirectoryHashes(directoryPath) {
176
+ const relativeFilePaths = await listRelativeFilePaths(directoryPath);
177
+ const hashEntries = await Promise.all(relativeFilePaths.map(async (relativeFilePath) => {
178
+ const fileBuffer = await fs.readFile(path.join(directoryPath, relativeFilePath));
179
+ return [
180
+ toPortableRelativePath(relativeFilePath),
181
+ createHash('sha256').update(fileBuffer).digest('hex'),
182
+ ];
183
+ }));
184
+ return Object.fromEntries(hashEntries);
185
+ }
186
+ /**
187
+ * Detects whether a managed skill directory has local content changes relative to the lockfile snapshot.
188
+ */
189
+ export async function detectLocalSkillEdits(input) {
190
+ if (!input.storedFiles) {
191
+ return {
192
+ changedFiles: [],
193
+ modified: false,
194
+ };
195
+ }
196
+ if (!(await fs.pathExists(input.skillDir))) {
197
+ return {
198
+ changedFiles: [],
199
+ modified: false,
200
+ };
201
+ }
202
+ const currentFiles = await computeDirectoryHashes(input.skillDir);
203
+ const changedFiles = [...new Set([...Object.keys(input.storedFiles), ...Object.keys(currentFiles)])]
204
+ .filter((relativeFilePath) => input.storedFiles?.[relativeFilePath] !== currentFiles[relativeFilePath])
205
+ .sort((left, right) => left.localeCompare(right));
206
+ return {
207
+ changedFiles,
208
+ modified: changedFiles.length > 0,
209
+ };
210
+ }
161
211
  /**
162
212
  * Clones a remote repository into a temporary checkout and resolves the fetched commit.
163
213
  */
@@ -268,6 +318,35 @@ export function shortCommit(commit) {
268
318
  export function timestampNow() {
269
319
  return new Date().toISOString();
270
320
  }
321
+ /**
322
+ * Recursively lists all file paths inside a directory relative to that directory root.
323
+ */
324
+ async function listRelativeFilePaths(directoryPath) {
325
+ const directoryEntries = await fs.readdir(directoryPath, {
326
+ withFileTypes: true,
327
+ });
328
+ const relativeFilePaths = [];
329
+ for (const directoryEntry of directoryEntries) {
330
+ const entryPath = path.join(directoryPath, directoryEntry.name);
331
+ if (directoryEntry.isDirectory()) {
332
+ const nestedFilePaths = await listRelativeFilePaths(entryPath);
333
+ for (const nestedFilePath of nestedFilePaths) {
334
+ relativeFilePaths.push(path.join(directoryEntry.name, nestedFilePath));
335
+ }
336
+ continue;
337
+ }
338
+ if (directoryEntry.isFile()) {
339
+ relativeFilePaths.push(directoryEntry.name);
340
+ }
341
+ }
342
+ return relativeFilePaths.sort((left, right) => left.localeCompare(right));
343
+ }
344
+ /**
345
+ * Normalizes a relative path to use forward slashes for lockfile portability.
346
+ */
347
+ function toPortableRelativePath(relativePath) {
348
+ return relativePath.split(path.sep).join('/');
349
+ }
271
350
  function sortSkillsLockfile(lockfile) {
272
351
  return {
273
352
  version: lockfile.version,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dryai",
3
- "version": "2.0.0",
3
+ "version": "2.1.0",
4
4
  "description": "CLI for installing shared AI commands, rules, and skills into Copilot and Cursor.",
5
5
  "type": "module",
6
6
  "repository": {
@@ -19,7 +19,8 @@
19
19
  "dev:dryai": "node ./dest/main.js",
20
20
  "tsc": "tsc -b",
21
21
  "test": "vitest run",
22
- "test:watch": "vitest"
22
+ "test:watch": "vitest",
23
+ "prepare": "husky"
23
24
  },
24
25
  "bin": {
25
26
  "dryai": "dest/main.js"
@@ -37,10 +38,14 @@
37
38
  "zod": "^3.24.1"
38
39
  },
39
40
  "devDependencies": {
41
+ "@commitlint/cli": "^20.5.0",
42
+ "@commitlint/config-conventional": "^20.5.0",
43
+ "@commitlint/types": "^20.5.0",
40
44
  "@effect/language-service": "^0.85.0",
41
45
  "@effect/vitest": "^0.29.0",
42
46
  "@types/fs-extra": "^11.0.4",
43
47
  "@types/node": "^24.0.0",
48
+ "husky": "^9.1.7",
44
49
  "typescript": "^5.8.3",
45
50
  "vitest": "^4.1.4"
46
51
  },