dryai 2.0.0 → 2.2.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
@@ -2,15 +2,17 @@
2
2
 
3
3
  Installs command, rule, and skill sources from `~/.config/dryai` by default into Copilot and Cursor targets.
4
4
 
5
- Pass `--config-root <path>` to read configs from a different root such as `./config`.
5
+ Global CLI options:
6
6
 
7
- Pass `--output-root <path>` to write generated output somewhere other than your home directory.
7
+ - `--config-root <path>` reads configs from a different root such as `./config`.
8
+ - `--output-root <path>` writes generated output somewhere other than your home directory.
9
+ - `--test` is a shortcut for `--output-root ./output-test`, and if both are provided, `--output-root` wins.
8
10
 
9
- `--test` is a shortcut for `--output-root ./output-test`, and if both are provided, `--output-root` wins.
11
+ These are root-level options for the CLI. They modify command behavior for any given command.
10
12
 
11
- ## Input
13
+ ## Config Layout
12
14
 
13
- Input config files live under the selected input root:
15
+ Input config files live under the selected config root:
14
16
 
15
17
  - `commands`
16
18
  - `rules`
@@ -24,9 +26,7 @@ Live output is written to:
24
26
  - `~/.cursor/rules`
25
27
  - `~/.cursor/skills`
26
28
 
27
- ## Example Configs
28
-
29
- One input root can contain all three source types:
29
+ One config root can contain all three source types:
30
30
 
31
31
  ```text
32
32
  ~/.config/dryai/
@@ -39,6 +39,106 @@ One input root can contain all three source types:
39
39
  └── SKILL.md
40
40
  ```
41
41
 
42
+ See VS Code editor setup note below.
43
+
44
+ ## Commands
45
+
46
+ ### `install`
47
+
48
+ - Purpose: Install commands, rules, and skills from the selected config root into the Copilot and Cursor target directories.
49
+ - Input roots: Reads from `commands`, `rules`, and `skills` under the selected config root.
50
+ - Output roots: Writes to `~/.copilot/prompts`, `~/.copilot/instructions`, `~/.copilot/skills`, `~/.cursor/rules`, and `~/.cursor/skills` by default.
51
+
52
+ ### `skills add`
53
+
54
+ - Purpose: Import one or more managed skills from a remote repository.
55
+ - Repository argument: `<repo>` may be a full git remote URL or a GitHub `owner/repo` shorthand such as `anthropics/skills`.
56
+ - Storage: Imported skills are copied into `config/skills/<name>/` and tracked in `skills.lock.json`.
57
+ - Config root: Local skill directories and `skills.lock.json` are read from and written to the selected config root.
58
+ - Required: `--skill <name>` is required at least once.
59
+ - Default resolution: Each requested skill resolves from `<repo root>/skills/<name>`.
60
+ - `--path <repoPath>`: Resolves each requested skill from a different base directory.
61
+ - `--path .`: Resolves each requested skill from the repository root itself.
62
+ - `--as <name>`: Stores the imported skill under a different local managed name when importing exactly one skill.
63
+ - `--ref <gitRef>`: Fetches a specific branch, tag, or commit instead of the remote default branch.
64
+ - `--pin`: Stores the resolved commit instead of tracking a moving ref.
65
+ - Examples:
66
+
67
+ ```sh
68
+ # Resolves from <repo root>/skills/skill-creator
69
+ dryai skills add anthropics/skills --skill skill-creator
70
+
71
+ # Resolves from <repo root>/review-helper
72
+ dryai skills add anthropics/skills --path . --skill review-helper
73
+
74
+ # Resolves from <repo root>/tools/review-helper
75
+ dryai skills add anthropics/skills --path tools --skill review-helper
76
+
77
+ # Resolves from <repo root>/skills/pr-review and <repo root>/skills/commit
78
+ dryai skills add vercel-labs/agent-skills --skill pr-review commit
79
+
80
+ # Resolves from <repo root>/skills/pr-review and <repo root>/skills/commit
81
+ dryai skills add https://github.com/vercel-labs/agent-skills.git --skill pr-review commit
82
+ ```
83
+
84
+ By default, imports track the requested ref. With no `--ref`, that means the remote default branch `HEAD` is tracked.
85
+
86
+ ### `skills update`
87
+
88
+ - Purpose: Re-fetch one managed skill from its tracked source and replace the local copied directory.
89
+ - `--force`: Overwrites local edits instead of skipping the update.
90
+
91
+ ### `skills update-all`
92
+
93
+ - Purpose: Re-fetch all managed skills from their tracked sources and replace the local copied directories.
94
+ - `--force`: Overwrites local edits instead of skipping the update.
95
+
96
+ ### `skills rehash`
97
+
98
+ - Purpose: Refresh the stored file hashes for one managed skill using its current local contents.
99
+
100
+ ### `skills rehash-all`
101
+
102
+ - Purpose: Refresh the stored file hashes for every managed skill using their current local contents.
103
+ - Behavior: Skips managed entries whose local directory is missing.
104
+
105
+ ### `skills remove`
106
+
107
+ - Purpose: Delete a managed skill's local copied directory and remove its lockfile entry.
108
+
109
+ ### `skills list`
110
+
111
+ - Purpose: Report local skill directories, annotate managed entries from the lockfile, and flag managed entries whose local directory is missing.
112
+
113
+ ### `skills` lockfile
114
+
115
+ The lockfile records:
116
+
117
+ - the local skill name
118
+ - the source repository
119
+ - the source path within that repository
120
+ - the requested git ref, when one was provided
121
+ - the resolved commit that was imported
122
+ - the last installed content hash for each file in the managed skill directory
123
+
124
+ 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.
125
+
126
+ For skills imported before hash tracking existed, use these commands to store hashes from the current local directory without fetching from the remote source:
127
+
128
+ ```sh
129
+ dryai skills rehash review-helper
130
+ dryai skills rehash-all
131
+ ```
132
+
133
+ Use these commands to intentionally overwrite local edits:
134
+
135
+ ```sh
136
+ dryai skills update review-helper --force
137
+ dryai skills update-all --force
138
+ ```
139
+
140
+ ## Example Configs
141
+
42
142
  ### Example Rule
43
143
 
44
144
  Rules are markdown files under `rules/`. `dryai` recognizes these rule frontmatter fields:
@@ -112,56 +212,9 @@ Focus on findings first.
112
212
  - Keep the overview brief unless the user asks for a deeper walkthrough.
113
213
  ```
114
214
 
115
- ## Commands
116
-
117
- ```sh
118
- dryai install
119
- dryai skills list List local skills
120
- dryai skills add [options] <repo> Add managed skills from a remote repository
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
124
- ```
125
-
126
- `<repo>` may be a full git remote URL or a GitHub `owner/repo` shorthand such as `anthropics/skills`.
127
-
128
- ## Managed Skills
129
-
130
- Imported skills are copied into `config/skills/<name>/` and tracked in `skills.lock.json`.
131
-
132
- When you run `skills` commands, local skill directories and `skills.lock.json` are read from and written to the selected config root.
133
-
134
- `skills add` requires at least one `--skill <name>` value. Each requested skill is always resolved from `<repo root>/skills/<name>`.
135
-
136
- Use `--as <name>` to choose a different local managed skill name when importing exactly one skill.
137
-
138
- Examples:
139
-
140
- ```sh
141
- dryai skills add anthropics/skills --skill skill-creator
142
- dryai skills add vercel-labs/agent-skills --skill pr-review commit
143
- dryai skills add https://github.com/vercel-labs/agent-skills.git --skill pr-review commit
144
- ```
145
-
146
- By default, imports track the requested ref. With no `--ref`, that means the remote default branch `HEAD` is tracked. Use `--pin` to store the currently resolved commit instead, so later `skills update` operations stay pinned to that commit.
147
-
148
- The lockfile records:
149
-
150
- - the local skill name
151
- - the source repository
152
- - the source path within that repository
153
- - the requested git ref, when one was provided
154
- - the resolved commit that was imported
155
-
156
- `skills update` and `skills update-all` re-fetch the tracked repository snapshot and replace the local copied skill directory.
157
-
158
- `skills remove` deletes the local copied skill directory and removes its lockfile entry.
159
-
160
- `skills list` reports local skill directories, annotating managed entries from the lockfile and flagging managed entries whose local directory is missing.
161
-
162
215
  ## Development
163
216
 
164
- For development, use `pnpm dev` to rebuild into `dest/` on change and `pnpm dev:dryai --test install` to run the built CLI.
217
+ For development, use `pnpm dev` to rebuild into `dest/` on change and `pnpm dev:dryai <...>` to run the built CLI.
165
218
 
166
219
  Run `pnpm run setup:editor` after installing dependencies if you want the Effect language service workspace patch applied locally.
167
220
 
@@ -172,39 +225,21 @@ pnpm run dev
172
225
  pnpm run test
173
226
  pnpm run test:watch
174
227
 
175
- pnpm dev:dryai install
176
- pnpm dev:dryai --test install
177
- pnpm dev:dryai --output-root ./tmp/install-root install
178
- pnpm dev:dryai --config-root ./config install
228
+ pnpm dev:dryai <...>
179
229
  ```
180
230
 
181
- ## CI and Release
182
-
183
- - On pull request open or update
184
- - Run CI validation with build, test, and `npm pack --dry-run`.
185
- - On changes landing on `main`
186
- - Run the same CI validation with build, test, and `npm pack --dry-run`.
231
+ ---
187
232
 
188
- - On `v*` tag pushed to `main`, the release workflow will:
189
- - Verify the tag matches the checked-in `package.json` version.
190
- - Verify the tagged commit is on `main`.
191
- - Build and test the CLI.
192
- - Create a tarball with `npm pack`.
193
- - Create or update the matching GitHub Release.
194
- - Upload the tarball as a release asset.
195
- - After the release workflow succeeds, the publish workflow will:
196
- - Download the tarball artifact produced by the release workflow.
197
- - Publish that exact tarball to npm using npm trusted publishing.
233
+ ## VS Code Setup
198
234
 
199
- Example release flow:
235
+ VS Code Copilot does not automatically discover prompt files from `~/.copilot/prompts`.
200
236
 
201
- ```sh
202
- git tag v0.1.0
203
- git push origin v0.1.0
204
- ```
237
+ Add this to your VS Code user settings so prompt files installed by `dryai` are picked up:
205
238
 
206
- Install from the release tarball with:
207
-
208
- ```sh
209
- npm install -g https://github.com/willmruzek/share-ai-config/releases/download/v0.1.0/share-ai-config-0.1.0.tgz
239
+ ```json
240
+ {
241
+ "chat.promptFilesLocations": {
242
+ "~/.copilot/prompts": true
243
+ }
244
+ }
210
245
  ```
@@ -1,9 +1,10 @@
1
1
  import type { AgentsContext } from '../../lib/context.js';
2
2
  /**
3
- * Imports one or more managed skills from a remote repository `skills/` directory into the local skills directory.
3
+ * Imports one or more managed skills from a remote repository into the local skills directory.
4
4
  */
5
5
  export declare function runSkillsAddCommand(context: AgentsContext, input: {
6
6
  repo: string;
7
+ repoPath: string | undefined;
7
8
  skillNames: string[];
8
9
  asName: string | undefined;
9
10
  pin: boolean;
@@ -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, deriveSkillName, ensureSkillsLockfile, ensureSkillsRoot, findManagedSkill, formatManagedSkillSummary, getManagedSkillDirectory, loadSkillsLockfile, normalizeImportedSkillPath, normalizeRemoteRepo, replaceManagedSkillDirectory, resolveManagedSkillImportPath, resolveManagedSkillImportPathFromBase, resolveSkillSourceDirByPath, 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
  */
@@ -20,10 +20,21 @@ function normalizeRequestedSkillNames(skillNames) {
20
20
  return uniqueSkillNames;
21
21
  }
22
22
  /**
23
- * Imports one or more managed skills from a remote repository `skills/` directory into the local skills directory.
23
+ * Resolves the remote repository path for one managed skill import request.
24
+ */
25
+ function resolveRequestedImportPath(input) {
26
+ const importPath = resolveManagedSkillImportPathFromBase({
27
+ basePath: input.basePath,
28
+ skillName: input.requestedSkillName,
29
+ });
30
+ return normalizeImportedSkillPath(importPath) ?? importPath;
31
+ }
32
+ /**
33
+ * Imports one or more managed skills from a remote repository into the local skills directory.
24
34
  */
25
35
  export async function runSkillsAddCommand(context, input) {
26
36
  const repo = normalizeRemoteRepo(input.repo);
37
+ const normalizedBasePath = normalizeImportedSkillPath(input.repoPath);
27
38
  if (input.skillNames.length === 0) {
28
39
  throw new Error('At least one skill name must be provided with --skill');
29
40
  }
@@ -45,9 +56,14 @@ export async function runSkillsAddCommand(context, input) {
45
56
  const importedSkillSummaries = [];
46
57
  try {
47
58
  for (const requestedSkillName of requestedSkillNames) {
48
- const skillName = input.asName ?? requestedSkillName;
49
- const importedSkillPath = resolveManagedSkillImportPath({
50
- skillName: requestedSkillName,
59
+ const importedSkillPath = resolveRequestedImportPath({
60
+ basePath: normalizedBasePath,
61
+ requestedSkillName,
62
+ });
63
+ const skillName = deriveSkillName({
64
+ repo,
65
+ skillPath: importedSkillPath,
66
+ explicitName: input.asName,
51
67
  });
52
68
  const existingManagedSkill = findManagedSkill(lockfile, {
53
69
  name: skillName,
@@ -60,17 +76,19 @@ export async function runSkillsAddCommand(context, input) {
60
76
  if (await fs.pathExists(targetDir)) {
61
77
  throw new Error(`A local skill directory already exists: ${targetDir}`);
62
78
  }
63
- const sourceDir = await resolveSkillSourceDir({
79
+ const sourceDir = await resolveSkillSourceDirByPath({
64
80
  checkoutDir: checkout.checkoutDir,
65
81
  repo,
66
- skillName: requestedSkillName,
82
+ skillPath: importedSkillPath,
67
83
  });
68
84
  await replaceManagedSkillDirectory({
69
85
  targetDir,
70
86
  sourceDir,
71
87
  });
88
+ const installedFiles = await computeDirectoryHashes(targetDir);
72
89
  const importedSkill = createImportedSkillRecord({
73
90
  commit: checkout.commit,
91
+ files: installedFiles,
74
92
  importedAt: timestampNow(),
75
93
  name: skillName,
76
94
  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';
@@ -12,8 +14,12 @@ const skillsImportOptionsSchema = z.object({
12
14
  skill: z.array(z.string()).optional(),
13
15
  as: nonEmptyOptionStringSchema.optional(),
14
16
  pin: z.boolean().optional().default(false),
17
+ path: nonEmptyOptionStringSchema.optional(),
15
18
  ref: nonEmptyOptionStringSchema.optional(),
16
19
  });
20
+ const skillsUpdateOptionsSchema = z.object({
21
+ force: z.boolean().optional().default(false),
22
+ });
17
23
  /**
18
24
  * Registers the managed skills command tree on the parent CLI program.
19
25
  */
@@ -29,7 +35,10 @@ export function addSkillsCommand(input) {
29
35
  Examples:
30
36
  ${commandName} list
31
37
  ${commandName} add anthropics/skills --skill skill-creator
38
+ ${commandName} add anthropics/skills --path . --skill review-helper
39
+ ${commandName} add anthropics/skills --path tools --skill review-helper
32
40
  ${commandName} add vercel-labs/agent-skills --skill pr-review commit
41
+ ${commandName} rehash skill-creator
33
42
  ${commandName} update skill-creator
34
43
  `)
35
44
  .action(() => {
@@ -44,7 +53,11 @@ export function addSkillsCommand(input) {
44
53
  skills
45
54
  .command('add <repo>')
46
55
  .description('Add managed skills from a remote repository')
47
- .option('--skill <names...>', 'Import one or more skills from the repository root skills/ directory')
56
+ .option('--skill <names...>', 'Import one or more skills by directory name')
57
+ .option('--path <repoPath>', 'Resolve each --skill from a different repository subdirectory; use . for the repository root instead of the default skills/ directory', parseOptionValue({
58
+ schema: nonEmptyOptionStringSchema,
59
+ optionLabel: '--path',
60
+ }))
48
61
  .option('--as <name>', 'Store the imported skill under a different local managed name', parseOptionValue({
49
62
  schema: nonEmptyOptionStringSchema,
50
63
  optionLabel: '--as',
@@ -62,6 +75,7 @@ export function addSkillsCommand(input) {
62
75
  });
63
76
  await runSkillsAddCommand(resolveContext(), {
64
77
  repo,
78
+ repoPath: parsedOptions.path,
65
79
  skillNames: parsedOptions.skill ?? [],
66
80
  asName: parsedOptions.as,
67
81
  pin: parsedOptions.pin,
@@ -74,17 +88,46 @@ export function addSkillsCommand(input) {
74
88
  .action(async (skillName) => {
75
89
  await runSkillsRemoveCommand(resolveContext(), { skillName });
76
90
  });
91
+ skills
92
+ .command('rehash <name>')
93
+ .description('Refresh stored file hashes for one managed skill')
94
+ .action(async (skillName) => {
95
+ await runSkillsRehashCommand(resolveContext(), { skillName });
96
+ });
97
+ skills
98
+ .command('rehash-all')
99
+ .description('Refresh stored file hashes for all managed skills')
100
+ .action(async () => {
101
+ await runSkillsRehashAllCommand(resolveContext());
102
+ });
77
103
  skills
78
104
  .command('update <name>')
79
105
  .description('Update a managed skill from its tracked source')
80
- .action(async (skillName) => {
81
- await runSkillsUpdateCommand(resolveContext(), { skillName });
106
+ .option('--force', 'Overwrite local skill edits with the fetched remote copy')
107
+ .action(async (skillName, options) => {
108
+ const parsedOptions = parseOptionsObject({
109
+ schema: skillsUpdateOptionsSchema,
110
+ options,
111
+ optionsLabel: 'skills update options',
112
+ });
113
+ await runSkillsUpdateCommand(resolveContext(), {
114
+ force: parsedOptions.force,
115
+ skillName,
116
+ });
82
117
  });
83
118
  skills
84
119
  .command('update-all')
85
120
  .description('Update all managed skills from their tracked sources')
86
- .action(async () => {
87
- await runSkillsUpdateAllCommand(resolveContext());
121
+ .option('--force', 'Overwrite local skill edits with the fetched remote copy')
122
+ .action(async (options) => {
123
+ const parsedOptions = parseOptionsObject({
124
+ schema: skillsUpdateOptionsSchema,
125
+ options,
126
+ optionsLabel: 'skills update-all options',
127
+ });
128
+ await runSkillsUpdateAllCommand(resolveContext(), {
129
+ force: parsedOptions.force,
130
+ });
88
131
  });
89
132
  return skills;
90
133
  }
@@ -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;
@@ -141,20 +153,52 @@ export declare function normalizeRemoteRepo(repo: string): string;
141
153
  export declare function resolveManagedSkillImportPath({ skillName, }: {
142
154
  skillName: string;
143
155
  }): string;
144
- export declare function normalizeImportedSkillPath(skillPath: string | undefined): string;
156
+ /**
157
+ * Normalizes an explicitly provided repository-relative skill path.
158
+ */
159
+ export declare function normalizeImportedSkillPath(skillPath: string | undefined): string | undefined;
160
+ /**
161
+ * Joins an optional base repository path with a requested managed skill name.
162
+ */
163
+ export declare function resolveManagedSkillImportPathFromBase(input: {
164
+ basePath: string | undefined;
165
+ skillName: string;
166
+ }): string;
167
+ /**
168
+ * Creates the lockfile record for a newly imported managed skill.
169
+ */
145
170
  export declare function createImportedSkillRecord(input: {
146
171
  commit: string;
172
+ files: ManagedSkillFiles;
147
173
  importedAt: string;
148
174
  name: string;
149
175
  path: string;
150
176
  ref: string | undefined;
151
177
  repo: string;
152
178
  }): ManagedSkill;
179
+ /**
180
+ * Creates the next lockfile record for a managed skill after its local contents have been refreshed.
181
+ */
153
182
  export declare function createUpdatedSkillRecord(input: {
154
183
  commit: string;
155
184
  existingSkill: ManagedSkill;
185
+ files: ManagedSkillFiles;
156
186
  updatedAt: string;
157
187
  }): ManagedSkill;
188
+ /**
189
+ * Computes stable SHA-256 hashes for every file within a managed skill directory.
190
+ */
191
+ export declare function computeDirectoryHashes(directoryPath: string): Promise<ManagedSkillFiles>;
192
+ /**
193
+ * Detects whether a managed skill directory has local content changes relative to the lockfile snapshot.
194
+ */
195
+ export declare function detectLocalSkillEdits(input: {
196
+ skillDir: string;
197
+ storedFiles: ManagedSkillFiles | undefined;
198
+ }): Promise<{
199
+ changedFiles: string[];
200
+ modified: boolean;
201
+ }>;
158
202
  /**
159
203
  * Clones a remote repository into a temporary checkout and resolves the fetched commit.
160
204
  */
@@ -170,6 +214,14 @@ export declare function resolveSkillSourceDir(input: {
170
214
  repo: string;
171
215
  skillName: string;
172
216
  }): Promise<string>;
217
+ /**
218
+ * Resolves and validates an arbitrary managed skill directory path from a temporary repository checkout.
219
+ */
220
+ export declare function resolveSkillSourceDirByPath(input: {
221
+ checkoutDir: string;
222
+ repo: string;
223
+ skillPath: string;
224
+ }): Promise<string>;
173
225
  /**
174
226
  * Fetches a validated remote skill directory snapshot for a specific repository path.
175
227
  */
@@ -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
  });
@@ -136,13 +139,38 @@ export function resolveManagedSkillImportPath({ skillName, }) {
136
139
  }
137
140
  return `skills/${trimmedSkillName}`;
138
141
  }
142
+ /**
143
+ * Normalizes an explicitly provided repository-relative skill path.
144
+ */
139
145
  export function normalizeImportedSkillPath(skillPath) {
140
- const normalizedPath = skillPath && skillPath.length > 0 ? skillPath : '.';
141
- return path.normalize(normalizedPath);
146
+ if (skillPath === undefined) {
147
+ return undefined;
148
+ }
149
+ return path.normalize(skillPath);
142
150
  }
151
+ /**
152
+ * Joins an optional base repository path with a requested managed skill name.
153
+ */
154
+ export function resolveManagedSkillImportPathFromBase(input) {
155
+ const normalizedBasePath = normalizeImportedSkillPath(input.basePath);
156
+ const defaultSkillPath = resolveManagedSkillImportPath({
157
+ skillName: input.skillName,
158
+ });
159
+ if (normalizedBasePath === '.') {
160
+ return path.normalize(input.skillName);
161
+ }
162
+ if (normalizedBasePath === undefined) {
163
+ return defaultSkillPath;
164
+ }
165
+ return path.normalize(path.join(normalizedBasePath, input.skillName));
166
+ }
167
+ /**
168
+ * Creates the lockfile record for a newly imported managed skill.
169
+ */
143
170
  export function createImportedSkillRecord(input) {
144
171
  return {
145
172
  commit: input.commit,
173
+ files: input.files,
146
174
  importedAt: input.importedAt,
147
175
  name: input.name,
148
176
  path: input.path,
@@ -151,13 +179,56 @@ export function createImportedSkillRecord(input) {
151
179
  updatedAt: input.importedAt,
152
180
  };
153
181
  }
182
+ /**
183
+ * Creates the next lockfile record for a managed skill after its local contents have been refreshed.
184
+ */
154
185
  export function createUpdatedSkillRecord(input) {
155
186
  return {
156
187
  ...input.existingSkill,
157
188
  commit: input.commit,
189
+ files: input.files,
158
190
  updatedAt: input.updatedAt,
159
191
  };
160
192
  }
193
+ /**
194
+ * Computes stable SHA-256 hashes for every file within a managed skill directory.
195
+ */
196
+ export async function computeDirectoryHashes(directoryPath) {
197
+ const relativeFilePaths = await listRelativeFilePaths(directoryPath);
198
+ const hashEntries = await Promise.all(relativeFilePaths.map(async (relativeFilePath) => {
199
+ const fileBuffer = await fs.readFile(path.join(directoryPath, relativeFilePath));
200
+ return [
201
+ toPortableRelativePath(relativeFilePath),
202
+ createHash('sha256').update(fileBuffer).digest('hex'),
203
+ ];
204
+ }));
205
+ return Object.fromEntries(hashEntries);
206
+ }
207
+ /**
208
+ * Detects whether a managed skill directory has local content changes relative to the lockfile snapshot.
209
+ */
210
+ export async function detectLocalSkillEdits(input) {
211
+ if (!input.storedFiles) {
212
+ return {
213
+ changedFiles: [],
214
+ modified: false,
215
+ };
216
+ }
217
+ if (!(await fs.pathExists(input.skillDir))) {
218
+ return {
219
+ changedFiles: [],
220
+ modified: false,
221
+ };
222
+ }
223
+ const currentFiles = await computeDirectoryHashes(input.skillDir);
224
+ const changedFiles = [...new Set([...Object.keys(input.storedFiles), ...Object.keys(currentFiles)])]
225
+ .filter((relativeFilePath) => input.storedFiles?.[relativeFilePath] !== currentFiles[relativeFilePath])
226
+ .sort((left, right) => left.localeCompare(right));
227
+ return {
228
+ changedFiles,
229
+ modified: changedFiles.length > 0,
230
+ };
231
+ }
161
232
  /**
162
233
  * Clones a remote repository into a temporary checkout and resolves the fetched commit.
163
234
  */
@@ -209,6 +280,25 @@ export async function resolveSkillSourceDir(input) {
209
280
  });
210
281
  return sourceDir;
211
282
  }
283
+ /**
284
+ * Resolves and validates an arbitrary managed skill directory path from a temporary repository checkout.
285
+ */
286
+ export async function resolveSkillSourceDirByPath(input) {
287
+ const normalizedSkillPath = normalizeImportedSkillPath(input.skillPath);
288
+ if (normalizedSkillPath === undefined) {
289
+ throw new Error('Skill path may not be empty');
290
+ }
291
+ const sourceDir = resolveRemoteSkillDirectory({
292
+ checkoutDir: input.checkoutDir,
293
+ skillPath: normalizedSkillPath,
294
+ });
295
+ await validateRemoteSkillDirectory({
296
+ sourceDir,
297
+ skillPath: normalizedSkillPath,
298
+ repo: normalizeRemoteRepo(input.repo),
299
+ });
300
+ return sourceDir;
301
+ }
212
302
  /**
213
303
  * Fetches a validated remote skill directory snapshot for a specific repository path.
214
304
  */
@@ -268,6 +358,35 @@ export function shortCommit(commit) {
268
358
  export function timestampNow() {
269
359
  return new Date().toISOString();
270
360
  }
361
+ /**
362
+ * Recursively lists all file paths inside a directory relative to that directory root.
363
+ */
364
+ async function listRelativeFilePaths(directoryPath) {
365
+ const directoryEntries = await fs.readdir(directoryPath, {
366
+ withFileTypes: true,
367
+ });
368
+ const relativeFilePaths = [];
369
+ for (const directoryEntry of directoryEntries) {
370
+ const entryPath = path.join(directoryPath, directoryEntry.name);
371
+ if (directoryEntry.isDirectory()) {
372
+ const nestedFilePaths = await listRelativeFilePaths(entryPath);
373
+ for (const nestedFilePath of nestedFilePaths) {
374
+ relativeFilePaths.push(path.join(directoryEntry.name, nestedFilePath));
375
+ }
376
+ continue;
377
+ }
378
+ if (directoryEntry.isFile()) {
379
+ relativeFilePaths.push(directoryEntry.name);
380
+ }
381
+ }
382
+ return relativeFilePaths.sort((left, right) => left.localeCompare(right));
383
+ }
384
+ /**
385
+ * Normalizes a relative path to use forward slashes for lockfile portability.
386
+ */
387
+ function toPortableRelativePath(relativePath) {
388
+ return relativePath.split(path.sep).join('/');
389
+ }
271
390
  function sortSkillsLockfile(lockfile) {
272
391
  return {
273
392
  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.2.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
  },