dotnet-production-agent-skills 0.1.1 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -10,7 +10,7 @@
10
10
 
11
11
  A focused collection of `SKILL.md` guidance built to reduce repeated prompting, unnecessary token use, and avoidable rework when developing production .NET software with AI coding tools.
12
12
 
13
- [Browse the skills catalog](https://dprakash2101.github.io/dotnet-production-agent-skills/#skills) · [Build an install command](https://dprakash2101.github.io/dotnet-production-agent-skills/#install) · [Report an issue](https://github.com/dprakash2101/dotnet-production-agent-skills/issues)
13
+ [Browse the skills catalog](https://dprakash2101.github.io/dotnet-production-agent-skills/#skills) · [Build an install command](https://dprakash2101.github.io/dotnet-production-agent-skills/#install) · [Release notes](RELEASE_NOTES.md) · [Report an issue](https://github.com/dprakash2101/dotnet-production-agent-skills/issues)
14
14
 
15
15
  </div>
16
16
 
@@ -36,6 +36,7 @@ A focused collection of `SKILL.md` guidance built to reduce repeated prompting,
36
36
  - [Safety & Conflict Handling](#safety--conflict-handling)
37
37
  - [Host Compatibility](#host-compatibility)
38
38
  - [Validation & Development](#validation--development)
39
+ - [Release Notes](#release-notes)
39
40
  - [Why I built this](#why-i-built-this)
40
41
  - [License](#license)
41
42
 
@@ -201,12 +202,14 @@ The catalog is grouped by task so agents can load only the guidance relevant to
201
202
  | [`google-cloud-pubsub-dotnet`](skills/google-cloud-pubsub-dotnet/SKILL.md) | Implement robust Google Cloud Pub/Sub publishers and pull subscribers with proper ack/nack semantics. | [client-patterns](skills/google-cloud-pubsub-dotnet/references/client-patterns.md) |
202
203
  | [`google-bigquery-dotnet`](skills/google-bigquery-dotnet/SKILL.md) | Query BigQuery with parameterization, pagination, streaming buffers, and strict query-cost awareness. | [query-safety](skills/google-bigquery-dotnet/references/query-safety.md) |
203
204
  | [`google-firestore-dotnet`](skills/google-firestore-dotnet/SKILL.md) | Model Firestore documents, transactions, and batched writes with concurrency and read-cost awareness. | — |
205
+ | [`redis-dotnet`](skills/redis-dotnet/SKILL.md) | Design secure .NET Redis caches, expiration, invalidation, and token protection. | — |
204
206
 
205
207
  ### 6. ⚡ Distributed Resilience & Security
206
208
 
207
209
  | Skill | Focus & Trigger | Progressive References |
208
210
  | :--- | :--- | :--- |
209
211
  | [`distributed-workflows`](skills/distributed-workflows/SKILL.md) | Build idempotent consumers, handle partial failures, manage distributed retries, and design background workers. | — |
212
+ | [`retry-resilience`](skills/retry-resilience/SKILL.md) | Add explicitly requested retries or review existing transient failure policies; avoid automatic retries. | — |
210
213
  | [`dotnet-security`](skills/dotnet-security/SKILL.md) | Threat-model .NET boundaries, protect against SSRF/XSS/SQLi, enforce authorization, and handle secrets safely. | — |
211
214
  | [`production-logging`](skills/production-logging/SKILL.md) | Produce structured, high-signal, sensitive-data-masked, and cost-efficient Serilog/ILogger production logs. | — |
212
215
 
@@ -277,10 +280,32 @@ External consumers are never assumed absent just because no internal repository
277
280
 
278
281
  ## TypeScript CLI Reference
279
282
 
280
- The installer CLI is written in TypeScript and compiles to dependency-free Node.js ESM (requiring Node.js >= 18).
283
+ ### How guidance is applied
284
+
285
+ | Concern | Mechanism | When it applies |
286
+ | :--- | :--- | :--- |
287
+ | Universal engineering rules | Managed section in project `.github/copilot-instructions.md` | Every Copilot coding task in that project |
288
+ | Specialized .NET knowledge | Individual `SKILL.md` files | The coding agent selects relevant skills from their descriptions and the request |
289
+ | Deterministic command enforcement | Optional host hooks/guardrails | Only when a supported host integration and policy are explicitly enabled |
290
+
291
+ The CLI installs and manages guidance; it does not route prompts or load every skill for every task. Clear skill descriptions help the coding agent select relevant knowledge. Universal rules belong in repository instructions. Command blocking belongs in host-supported hooks. Hook installation is currently an extension point in `src/copilot/hooks.ts`; no command hook is installed because a portable supported Copilot hook configuration has not been established.
292
+
293
+ Copilot project installs manage only the text between `<!-- dotnet-production-agent-skills:start -->` and `<!-- dotnet-production-agent-skills:end -->`. Existing team instructions outside those markers remain intact. Uninstall removes only the managed section. User-scope Copilot installs install skills into `~/.copilot/skills`; repository instructions apply only to project scope.
294
+
295
+ ```sh
296
+ dotnet-production-agent-skills install --target copilot --scope project
297
+ dotnet-production-agent-skills install --target copilot --scope user
298
+ dotnet-production-agent-skills update --target copilot --scope project
299
+ dotnet-production-agent-skills doctor --target copilot --scope project
300
+ dotnet-production-agent-skills list --target copilot --scope project
301
+ ```
302
+
303
+ `--mode copy` installs portable snapshots, while `--mode link` creates links to the installed npm package and depends on that package remaining at the same location. Use `--dry-run` to preview changes without writing. Repeated installs skip unchanged snapshots; updates replace snapshots only when the packaged skill changes. Forced replacement or removal backs up locally modified skills.
304
+
305
+ The installer CLI is written in TypeScript and compiles to Node.js ESM (requiring Node.js >= 18). The runtime uses the small `yaml` dependency to validate skill metadata.
281
306
 
282
307
  ```text
283
- dotnet-agent-skills <command> [options]
308
+ dotnet-production-agent-skills <command> [options]
284
309
 
285
310
  Commands:
286
311
  install Install canonical skills into agent discovery paths
@@ -374,6 +399,10 @@ npm pack --dry-run
374
399
 
375
400
  ---
376
401
 
402
+ ## Release Notes
403
+
404
+ See [release notes](RELEASE_NOTES.md) or the [documentation page](https://dprakash2101.github.io/dotnet-production-agent-skills/release-notes.html) for changes, compatibility notes, and publication status.
405
+
377
406
  ## Why I built this
378
407
 
379
408
  I created **dotnet-production-agent-skills** after repeatedly explaining the same production expectations to GitHub Copilot—then spending more tokens correcting and reworking the result. This project captures those lessons as focused, reusable guidance so coding agents can start with better context and produce safer .NET code the first time.
@@ -0,0 +1,40 @@
1
+ # Release notes
2
+
3
+ ## Unreleased — Redis caching skill
4
+
5
+ Proposed in [PR #6](https://github.com/dprakash2101/dotnet-production-agent-skills/pull/6); this change is not yet published to npm.
6
+
7
+ ### New
8
+
9
+ - Added `redis-dotnet` guidance for .NET and ASP.NET Core Redis caching: key design, expiration, invalidation, concurrency, failure handling, and targeted validation.
10
+ - The skill prohibits plaintext credentials in Redis. Credential ciphertext must be bound to the cache key, trusted tenant/account, and credential purpose using authenticated associated data or equivalent purpose isolation, with encryption keys outside Redis. Tests must reject ciphertext copied to another key, tenant/account, or purpose.
11
+ - Added the skill to the README and website catalog.
12
+
13
+ ## v0.2.0 — Copilot instructions and safer skill management
14
+
15
+ The implementation was merged in [PR #4](https://github.com/dprakash2101/dotnet-production-agent-skills/pull/4) and published as [`dotnet-production-agent-skills@0.2.0`](https://www.npmjs.com/package/dotnet-production-agent-skills/v/0.2.0). These notes document that release; the release notes page is a follow-up documentation change.
16
+
17
+ ### New
18
+
19
+ - Copilot project installs manage an always-on engineering section in `.github/copilot-instructions.md`. Existing team instructions outside the managed markers remain intact; uninstall removes only the managed section.
20
+ - A `retry-resilience` skill covers explicitly requested retries and reviews of existing retry policies. Its guidance avoids introducing retries automatically.
21
+ - The CLI has a separate Copilot hooks interface for future command guardrails. No hooks are installed by this change.
22
+
23
+ ### Improved
24
+
25
+ - Repeated installs skip unchanged managed skills. An updated packaged skill replaces an unchanged installed copy.
26
+ - Forced uninstall backs up locally edited managed skills and reports the backup path.
27
+ - `doctor` parses YAML frontmatter, accepts multiline descriptions, checks skill metadata and local references, reports packaged updates and Copilot instruction state, and exits nonzero for installation or validation errors.
28
+ - Skill descriptions now give agents clearer cues for selecting task-specific guidance. Copilot installation output includes skill discovery commands.
29
+ - CLI help displays the published executable name, `dotnet-production-agent-skills`.
30
+
31
+ ### Compatibility and upgrade
32
+
33
+ - Targets (`all`, `shared`, `codex`, `copilot`, `claude`, `cursor`), user and project scopes, copy and link modes, and existing CLI flags remain supported.
34
+ - Copilot instructions are added only for project-scope Copilot installs (including `--target all --scope project`). User-scope Copilot installs continue to install skills in `~/.copilot/skills`.
35
+ - Review any local skill edits before updating. The CLI still requires `--force --yes` for noninteractive replacement of conflicts, with a backup of the replaced content.
36
+ - The CLI now depends on the `yaml` npm package. Install the package normally so npm resolves its runtime dependency.
37
+
38
+ ### Validation
39
+
40
+ `npm run validate` passed: 30 packaged skills validated and 14 CLI tests passed. The CLI help, Copilot install dry run, doctor output, package dry run, and `git diff --check` were also exercised.
@@ -0,0 +1,8 @@
1
+ Understand the existing implementation before changing code. Follow its architecture and conventions.
2
+ Reuse existing helpers and services; avoid duplicated logic and keep new code maintainable under repository analyzers and SonarCloud.
3
+ Preserve compatibility and public API contracts unless a change is explicitly requested. Avoid unrelated refactors.
4
+ Never expose stack traces, credentials, tokens, secrets, connection strings, internal URLs, or database details through customer-facing APIs.
5
+ Never log secrets or sensitive payloads. Add operationally useful logs, and limit noise in high-traffic APIs.
6
+ Do not introduce retries automatically. Add them only when requested or required by existing architecture, and only for safe transient operations.
7
+ When behavior changes, check whether existing tests need updating. Avoid redundant tests that slow CI.
8
+ Run the smallest appropriate validation, build, or test command after changing code.
package/dist/src/cli.js CHANGED
@@ -1,18 +1,19 @@
1
1
  #!/usr/bin/env node
2
2
  import { createHash, randomUUID } from "node:crypto";
3
- import { existsSync } from "node:fs";
4
3
  import { cp, lstat, mkdir, readFile, readdir, readlink, realpath, rename, rm, symlink, writeFile, } from "node:fs/promises";
5
4
  import { homedir } from "node:os";
6
5
  import path from "node:path";
7
6
  import process from "node:process";
8
7
  import { fileURLToPath } from "node:url";
9
8
  import { createInterface } from "node:readline/promises";
9
+ import { changeInstructions, inspectInstructions } from "./copilot/instructions.js";
10
+ import { validateSkills } from "./skills/validation.js";
10
11
  const PACKAGE_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..");
11
12
  const SKILLS_ROOT = path.join(PACKAGE_ROOT, "skills");
12
13
  const MANIFEST_NAME = ".dotnet-agent-skills.json";
13
14
  const SUPPORTED_TARGETS = new Set(["all", "shared", "codex", "copilot", "claude", "cursor"]);
14
15
  function usage() {
15
- return `Usage: dotnet-agent-skills <command> [options]
16
+ return `Usage: dotnet-production-agent-skills <command> [options]
16
17
 
17
18
  Commands:
18
19
  install Install canonical skills
@@ -226,16 +227,28 @@ async function moveToBackup(destination, name, target) {
226
227
  }
227
228
  async function installOrUpdate(options, command) {
228
229
  const skills = await packagedSkills();
230
+ const validationErrors = await validateCanonical();
231
+ if (validationErrors.length)
232
+ throw new Error(`invalid packaged skills: ${validationErrors.join("; ")}`);
233
+ if ((options.target === "copilot" || options.target === "all") && options.scope === "project") {
234
+ const instructions = await inspectInstructions(options.project);
235
+ if (instructions.state === "malformed")
236
+ throw new Error(`malformed managed Copilot instructions: ${instructions.path}`);
237
+ }
229
238
  const packaged = new Set(skills);
230
239
  const packageJson = JSON.parse(await readFile(path.join(PACKAGE_ROOT, "package.json"), "utf8"));
231
240
  const destinationPlans = [];
241
+ const digests = new Map(await Promise.all(skills.map(async (name) => [name, await digestDirectory(path.join(SKILLS_ROOT, name))])));
232
242
  for (const destination of destinations(options)) {
233
243
  const manifest = await readManifest(destination.directory);
234
244
  const plans = [];
235
245
  for (const name of skills) {
236
246
  const state = await currentState(destination.directory, name, manifest.skills[name]);
237
- const action = state.state === "missing" ? "install" : state.state === "unchanged" ? "replace" : "conflict";
238
- plans.push({ name, state, action });
247
+ const digest = digests.get(name);
248
+ const action = state.state === "missing" ? "install" : state.state === "unchanged"
249
+ ? manifest.skills[name]?.digest === digest && manifest.skills[name]?.mode === options.mode ? "skip" : "replace"
250
+ : "conflict";
251
+ plans.push({ name, state, action, digest });
239
252
  }
240
253
  const retirements = [];
241
254
  if (command === "update") {
@@ -259,6 +272,10 @@ async function installOrUpdate(options, command) {
259
272
  if (!options.dryRun)
260
273
  await mkdir(destination.directory, { recursive: true });
261
274
  for (const plan of plans) {
275
+ if (plan.action === "skip") {
276
+ console.log(`skip ${plan.state.installedPath}`);
277
+ continue;
278
+ }
262
279
  const source = path.join(SKILLS_ROOT, plan.name);
263
280
  const target = plan.state.installedPath;
264
281
  if (plan.action !== "install") {
@@ -281,7 +298,7 @@ async function installOrUpdate(options, command) {
281
298
  await cp(source, target, { recursive: true, errorOnExist: true });
282
299
  }
283
300
  manifest.skills[plan.name] = {
284
- digest: await digestDirectory(source),
301
+ digest: plan.digest,
285
302
  mode: options.mode,
286
303
  source: options.mode === "link" ? await realpath(source) : null,
287
304
  };
@@ -305,8 +322,19 @@ async function installOrUpdate(options, command) {
305
322
  manifest.target = destination.target;
306
323
  await writeManifest(destination.directory, manifest, options.dryRun);
307
324
  }
325
+ if ((options.target === "copilot" || options.target === "all") && options.scope === "project") {
326
+ const action = await changeInstructions(options.project, "install", options.dryRun);
327
+ console.log(`${action === "skip" ? "skip" : options.dryRun ? `would ${action}` : action} Copilot production instructions`);
328
+ if (!options.dryRun)
329
+ console.log("Copilot CLI: /skills list | /skills reload | /skills info <skill-name>");
330
+ }
308
331
  }
309
332
  async function uninstall(options) {
333
+ if ((options.target === "copilot" || options.target === "all") && options.scope === "project") {
334
+ const instructions = await inspectInstructions(options.project);
335
+ if (instructions.state === "malformed")
336
+ throw new Error(`malformed managed Copilot instructions: ${instructions.path}`);
337
+ }
310
338
  const destinationPlans = [];
311
339
  for (const destination of destinations(options)) {
312
340
  const manifest = await readManifest(destination.directory);
@@ -329,8 +357,14 @@ async function uninstall(options) {
329
357
  for (const { destination, manifest, skills: plans } of destinationPlans) {
330
358
  for (const plan of plans) {
331
359
  console.log(`${options.dryRun ? "would remove" : "remove"} ${plan.state.installedPath}`);
332
- if (!options.dryRun)
333
- await rm(plan.state.installedPath, { recursive: true, force: true });
360
+ if (!options.dryRun) {
361
+ if (plan.state.state === "modified") {
362
+ const backup = await moveToBackup(destination.directory, plan.name, plan.state.installedPath);
363
+ console.log(`backup ${backup}`);
364
+ }
365
+ else
366
+ await rm(plan.state.installedPath, { recursive: true, force: true });
367
+ }
334
368
  delete manifest.skills[plan.name];
335
369
  }
336
370
  if (!options.dryRun) {
@@ -341,48 +375,40 @@ async function uninstall(options) {
341
375
  await writeManifest(destination.directory, manifest, false);
342
376
  }
343
377
  }
378
+ if ((options.target === "copilot" || options.target === "all") && options.scope === "project") {
379
+ const action = await changeInstructions(options.project, "uninstall", options.dryRun);
380
+ if (action !== "skip")
381
+ console.log(`${options.dryRun ? "would remove" : "remove"} Copilot production instructions`);
382
+ }
344
383
  }
345
384
  async function inspect(options) {
346
385
  const skills = await packagedSkills();
347
386
  const packaged = new Set(skills);
387
+ const packageJson = JSON.parse(await readFile(path.join(PACKAGE_ROOT, "package.json"), "utf8"));
348
388
  const installations = [];
389
+ const digests = new Map(await Promise.all(skills.map(async (name) => [name, await digestDirectory(path.join(SKILLS_ROOT, name))])));
349
390
  for (const destination of destinations(options)) {
350
391
  const manifest = await readManifest(destination.directory);
351
392
  const states = [];
352
393
  for (const name of skills) {
353
394
  const state = await currentState(destination.directory, name, manifest.skills[name]);
354
- states.push({ name, state: state.state, mode: manifest.skills[name]?.mode ?? null });
395
+ const record = manifest.skills[name];
396
+ states.push({ name, state: state.state, mode: record?.mode ?? null, packaged: record ? record.digest === digests.get(name) ? "current" : "outdated" : null });
355
397
  }
356
398
  for (const [name, record] of Object.entries(manifest.skills)) {
357
399
  if (!packaged.has(name))
358
- states.push({ name, state: "stale", mode: record.mode });
400
+ states.push({ name, state: "stale", mode: record.mode, packaged: null });
359
401
  }
360
- installations.push({ ...destination, packageVersion: manifest.packageVersion, skills: states });
402
+ installations.push({ ...destination, packageVersion: manifest.packageVersion,
403
+ packageVersionMismatch: manifest.packageVersion !== null && manifest.packageVersion !== packageJson.version,
404
+ skills: states });
361
405
  }
362
- return { packagedSkills: skills, installations };
406
+ const copilotInstructions = (options.target === "copilot" || options.target === "all") && options.scope === "project"
407
+ ? await inspectInstructions(options.project) : undefined;
408
+ return { packagedSkills: skills, currentPackageVersion: packageJson.version, installations, ...(copilotInstructions ? { copilotInstructions } : {}) };
363
409
  }
364
410
  async function validateCanonical() {
365
- const errors = [];
366
- for (const name of await packagedSkills()) {
367
- const skillFile = path.join(SKILLS_ROOT, name, "SKILL.md");
368
- if (!existsSync(skillFile)) {
369
- errors.push(`${name}: missing SKILL.md`);
370
- continue;
371
- }
372
- const contents = await readFile(skillFile, "utf8");
373
- const match = contents.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n/);
374
- if (!match?.[1])
375
- errors.push(`${name}: invalid frontmatter delimiters`);
376
- else {
377
- const declaredName = match[1].match(/^name:\s*(.+)$/m)?.[1]?.trim();
378
- const description = match[1].match(/^description:\s*(.+)$/m)?.[1]?.trim();
379
- if (declaredName !== name)
380
- errors.push(`${name}: frontmatter name does not match directory`);
381
- if (!description)
382
- errors.push(`${name}: missing description`);
383
- }
384
- }
385
- return errors;
411
+ return validateSkills(SKILLS_ROOT, await packagedSkills());
386
412
  }
387
413
  function countStates(skills) {
388
414
  return skills.reduce((counts, entry) => {
@@ -414,8 +440,9 @@ async function main() {
414
440
  const report = await inspect(parsed.options);
415
441
  const validationErrors = command === "doctor" ? await validateCanonical() : [];
416
442
  const unhealthy = report.installations.flatMap((entry) => entry.skills)
417
- .filter((entry) => entry.state !== "unchanged");
418
- const output = { ...report, validationErrors, healthy: validationErrors.length === 0 && unhealthy.length === 0 };
443
+ .filter((entry) => entry.state !== "unchanged" || entry.packaged === "outdated");
444
+ const instructionsUnhealthy = report.copilotInstructions && !["unchanged", "unmanaged"].includes(report.copilotInstructions.state);
445
+ const output = { ...report, validationErrors, healthy: validationErrors.length === 0 && unhealthy.length === 0 && !instructionsUnhealthy };
419
446
  if (parsed.options.json)
420
447
  console.log(JSON.stringify(output, null, 2));
421
448
  else {
@@ -424,7 +451,14 @@ async function main() {
424
451
  const summary = Object.entries(countStates(installation.skills))
425
452
  .map(([key, value]) => `${key}=${value}`).join(" ");
426
453
  console.log(`${installation.target.padEnd(7)} ${installation.directory} ${summary}`);
454
+ if (installation.packageVersionMismatch)
455
+ console.log(` installed package version: ${installation.packageVersion}; current: ${report.currentPackageVersion}`);
456
+ const outdated = installation.skills.filter((skill) => skill.packaged === "outdated");
457
+ if (outdated.length)
458
+ console.log(` packaged updates: ${outdated.map((skill) => skill.name).join(", ")}`);
427
459
  }
460
+ if (report.copilotInstructions)
461
+ console.log(`Copilot instructions: ${report.copilotInstructions.state} ${report.copilotInstructions.path}`);
428
462
  validationErrors.forEach((error) => console.error(`error: ${error}`));
429
463
  }
430
464
  if (command === "doctor" && !output.healthy)
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,74 @@
1
+ import { readFile, mkdir, rm, writeFile } from "node:fs/promises";
2
+ import path from "node:path";
3
+ import { fileURLToPath } from "node:url";
4
+ const start = "<!-- dotnet-production-agent-skills:start -->";
5
+ const end = "<!-- dotnet-production-agent-skills:end -->";
6
+ const source = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../../../copilot/production-instructions.md");
7
+ export async function instructionsPath(base) {
8
+ return path.join(base, ".github", "copilot-instructions.md");
9
+ }
10
+ async function optionalRead(file) {
11
+ try {
12
+ return await readFile(file, "utf8");
13
+ }
14
+ catch (error) {
15
+ if (error instanceof Error && "code" in error && error.code === "ENOENT")
16
+ return null;
17
+ throw error;
18
+ }
19
+ }
20
+ function section(text) {
21
+ const from = text.indexOf(start);
22
+ const closing = text.indexOf(end);
23
+ if ((from < 0) !== (closing < 0) || (from >= 0 && (closing < from || text.indexOf(start, from + start.length) >= 0 || text.indexOf(end, closing + end.length) >= 0))) {
24
+ throw new Error("malformed managed Copilot instructions section");
25
+ }
26
+ return from < 0 ? null : { from, to: closing + end.length };
27
+ }
28
+ export async function inspectInstructions(base) {
29
+ const file = await instructionsPath(base);
30
+ const existing = await optionalRead(file);
31
+ if (existing === null)
32
+ return { path: file, state: "missing" };
33
+ let bounds;
34
+ try {
35
+ bounds = section(existing);
36
+ }
37
+ catch {
38
+ return { path: file, state: "malformed" };
39
+ }
40
+ if (!bounds)
41
+ return { path: file, state: "unmanaged" };
42
+ const canonical = (await readFile(source, "utf8")).trim();
43
+ const managed = existing.slice(bounds.from + start.length, bounds.to - end.length).trim();
44
+ return { path: file, state: managed === canonical ? "unchanged" : "outdated" };
45
+ }
46
+ export async function changeInstructions(base, operation, dryRun) {
47
+ const file = await instructionsPath(base);
48
+ const existing = await optionalRead(file);
49
+ const bounds = section(existing ?? "");
50
+ if (operation === "uninstall") {
51
+ if (!bounds || existing === null)
52
+ return "skip";
53
+ const remaining = `${existing.slice(0, bounds.from).trimEnd()}${existing.slice(bounds.to).trimStart()}`.trim();
54
+ if (!dryRun) {
55
+ if (remaining)
56
+ await writeFile(file, `${remaining}\n`, "utf8");
57
+ else
58
+ await rm(file);
59
+ }
60
+ return "remove";
61
+ }
62
+ const canonical = (await readFile(source, "utf8")).trim();
63
+ const managed = `${start}\n${canonical}\n${end}`;
64
+ const next = bounds && existing !== null
65
+ ? `${existing.slice(0, bounds.from)}${managed}${existing.slice(bounds.to)}`
66
+ : `${existing?.trimEnd() ? `${existing.trimEnd()}\n\n` : ""}${managed}\n`;
67
+ if (next === existing)
68
+ return "skip";
69
+ if (!dryRun) {
70
+ await mkdir(path.dirname(file), { recursive: true });
71
+ await writeFile(file, next, "utf8");
72
+ }
73
+ return bounds ? "update" : "install";
74
+ }
@@ -0,0 +1,65 @@
1
+ import { readFile, stat } from "node:fs/promises";
2
+ import path from "node:path";
3
+ import YAML from "yaml";
4
+ const safeName = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
5
+ export async function validateSkills(root, names) {
6
+ const errors = [];
7
+ const declared = new Set();
8
+ for (const name of names) {
9
+ if (!safeName.test(name) || name.length > 64) {
10
+ errors.push(`${name}: unsafe skill directory name`);
11
+ continue;
12
+ }
13
+ const directory = path.join(root, name);
14
+ let contents;
15
+ try {
16
+ contents = await readFile(path.join(directory, "SKILL.md"), "utf8");
17
+ }
18
+ catch {
19
+ errors.push(`${name}: missing or unreadable SKILL.md`);
20
+ continue;
21
+ }
22
+ const match = /^---\r?\n([\s\S]*?)\r?\n---(?:\r?\n|$)/.exec(contents);
23
+ if (!match) {
24
+ errors.push(`${name}: invalid frontmatter delimiters`);
25
+ continue;
26
+ }
27
+ const document = YAML.parseDocument(match[1], { uniqueKeys: true });
28
+ if (document.errors.length) {
29
+ errors.push(`${name}: invalid YAML: ${document.errors[0].message}`);
30
+ continue;
31
+ }
32
+ const metadata = document.toJS();
33
+ if (!metadata || typeof metadata !== "object" || Array.isArray(metadata)) {
34
+ errors.push(`${name}: frontmatter must be a mapping`);
35
+ continue;
36
+ }
37
+ const fields = metadata;
38
+ if (typeof fields.name !== "string" || fields.name !== name)
39
+ errors.push(`${name}: frontmatter name does not match directory`);
40
+ if (typeof fields.description !== "string" || !fields.description.trim())
41
+ errors.push(`${name}: missing description`);
42
+ if (typeof fields.name === "string") {
43
+ if (declared.has(fields.name))
44
+ errors.push(`${name}: duplicate skill name ${fields.name}`);
45
+ declared.add(fields.name);
46
+ }
47
+ for (const link of contents.matchAll(/\]\(([^)#]+)(?:#[^)]*)?\)/g)) {
48
+ const target = link[1];
49
+ if (/^[a-z]+:/i.test(target) || target.startsWith("/"))
50
+ continue;
51
+ const resolved = path.resolve(directory, target);
52
+ if (!resolved.startsWith(`${directory}${path.sep}`)) {
53
+ errors.push(`${name}: linked file escapes skill directory: ${target}`);
54
+ continue;
55
+ }
56
+ try {
57
+ await stat(resolved);
58
+ }
59
+ catch {
60
+ errors.push(`${name}: missing linked file: ${target}`);
61
+ }
62
+ }
63
+ }
64
+ return errors;
65
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dotnet-production-agent-skills",
3
- "version": "0.1.1",
3
+ "version": "0.2.1",
4
4
  "description": "Portable production .NET engineering skills for coding agents",
5
5
  "license": "MIT",
6
6
  "author": {
@@ -21,7 +21,9 @@
21
21
  },
22
22
  "files": [
23
23
  "dist/src",
24
+ "copilot",
24
25
  "skills",
26
+ "RELEASE_NOTES.md",
25
27
  "README.md",
26
28
  "LICENSE"
27
29
  ],
@@ -49,5 +51,8 @@
49
51
  "copilot",
50
52
  "claude-code",
51
53
  "cursor"
52
- ]
54
+ ],
55
+ "dependencies": {
56
+ "yaml": "^2.9.1"
57
+ }
53
58
  }
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: api-contract-safety
3
- description: Preserve and evolve ASP.NET Core API contracts safely. Use when changing routes, verbs, headers, parameters, DTOs, JSON, status/errors, enums, auth, versioning, or public interfaces.
3
+ description: Use when changing or reviewing existing API routes, versions, verbs, headers, request/response DTOs, serialization, status codes, error shapes, authorization, or public interfaces.
4
4
  ---
5
5
 
6
6
  # API contract safety
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: api-endpoint-deprecation
3
- description: Plan, implement, or review deprecation and eventual removal of a customer-facing API endpoint. Use for endpoint replacement, version retirement, deprecation metadata, migration windows, usage verification, or explicit removal work.
3
+ description: Use when planning, implementing, or reviewing API deprecation, replacement versions, sunset headers, migration windows, consumer verification, or endpoint removal.
4
4
  ---
5
5
 
6
6
  # Safe API endpoint deprecation
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: aspnet-core-api-development
3
- description: Create a new customer-facing ASP.NET Core API endpoint or API surface. Use for new controllers/minimal endpoints and their request, validation, service, integration, response, and OpenAPI flow; not for small changes to an existing endpoint.
3
+ description: Use when building a new ASP.NET Core customer-facing API endpoint or version, including routing, header and request validation, service boundaries, authorization, error responses, and OpenAPI. Use api-contract-safety when modifying an existing contract.
4
4
  ---
5
5
 
6
6
  # New customer-facing ASP.NET Core APIs
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: code-quality
3
- description: Improve or assess quality of changed C#/.NET code under Clean-as-You-Code and analyzer/Sonar expectations. Use for requested quality work or while polishing a substantive implementation; do not trigger repository-wide legacy cleanup.
3
+ description: Use when improving or reviewing modified .NET code for SonarCloud/Roslyn findings, duplication, complexity, maintainability, or existing helper reuse. Avoid unrelated legacy cleanup.
4
4
  ---
5
5
 
6
6
  # .NET code quality
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: distributed-workflows
3
- description: Design or change .NET message handlers, background services, retries, idempotent operations, and multi-system workflows. Use when work crosses process/service boundaries or can execute more than once.
3
+ description: Use when designing or changing .NET message handlers, background workers, idempotency, duplicate delivery, partial failures, or existing retry behavior. Introduce new retries only when requested or required by existing architecture.
4
4
  ---
5
5
 
6
6
  # Distributed workflows
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: exception-handling
3
- description: Design or review .NET exception handling, including catch boundaries, recovery, translation, cancellation, and logging ownership. Use when adding or changing exception paths or centralized handlers; load the API reference only for customer-facing HTTP boundaries.
3
+ description: Use when implementing, modifying, or reviewing .NET exception handling, customer-facing API error translation, recovery boundaries, cancellation, and safe logging. Prevent internal details and secrets from reaching callers.
4
4
  ---
5
5
 
6
6
  # .NET exception handling
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: google-bigquery-dotnet
3
- description: Implement or review Google BigQuery access from .NET with Google.Cloud.BigQuery.V2. Use only for BigQuery queries, jobs, result mapping, authentication, performance, or query-cost concerns.
3
+ description: Use when implementing, modifying, reviewing, troubleshooting, or optimizing Google BigQuery integrations in .NET, including BigQueryClient, authentication, parameterized queries, DATE/DATETIME/TIMESTAMP types, query costs, serialization, and result mapping.
4
4
  ---
5
5
 
6
6
  # Google BigQuery for .NET
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: google-cloud-pubsub-dotnet
3
- description: Implement or review Google Cloud Pub/Sub publishers and pull subscribers in .NET. Use only for Google.Cloud.PubSub.V1 messaging, subscriber workers, acknowledgement behavior, or Pub/Sub delivery concerns.
3
+ description: Use when implementing, modifying, reviewing, or troubleshooting Google Cloud Pub/Sub publishers, subscribers, pull workers, acknowledgements, concurrency, flow control, dead-letter handling, ordering, or BackgroundService integration in .NET.
4
4
  ---
5
5
 
6
6
  # Google Cloud Pub/Sub for .NET
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: google-firestore-dotnet
3
- description: Implement or review Google Cloud Firestore persistence in .NET. Use only for Google.Cloud.Firestore document models, reads/writes, queries, transactions, batches, indexes, or Firestore cost/modeling decisions.
3
+ description: Use when implementing, reviewing, or troubleshooting .NET Firestore document models, queries, transactions, batched writes, indexes, optimistic concurrency, or read/write costs.
4
4
  ---
5
5
 
6
6
  # Google Cloud Firestore for .NET
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: production-logging
3
- description: Design or review structured .NET logging for production and high-volume APIs. Use when adding/changing logs, defining logging ownership, reducing noise/cost, or protecting sensitive observability data.
3
+ description: Use when adding, reviewing, or reducing .NET structured logs, exception telemetry, correlation IDs, sensitive-data exposure, or high-traffic API logging costs.
4
4
  ---
5
5
 
6
6
  # Production logging for .NET
@@ -0,0 +1,16 @@
1
+ ---
2
+ name: redis-dotnet
3
+ description: Implement, review, or troubleshoot Redis caching in .NET and ASP.NET Core, including StackExchange.Redis, IDistributedCache, key design, expiration, cache invalidation, performance, and secure handling of cached tokens or secrets.
4
+ ---
5
+
6
+ # Redis caching for .NET
7
+
8
+ Inspect the application's existing cache abstraction, Redis client registration, serializers, and failure policy before changing code. Reuse established helpers and avoid adding a second connection or serialization scheme without a reason.
9
+
10
+ - Choose a stable namespaced key with environment and tenant/customer isolation where needed. Do not place access tokens, secret keys, passwords, or sensitive personal data in key names; keys appear in diagnostics and operational tools.
11
+ - Set an explicit TTL from the data's real validity. For access tokens, expire the cache entry before the token's expiry with a small safety margin, and handle refresh races across instances. Define invalidation when source data changes. Avoid unbounded or permanently cached entries by default.
12
+ - Keep values small and version serialized payloads when schemas can change. Do not cache null/error responses without a deliberate short-lived policy. Avoid large scans, blocking commands, and per-request connection creation; bound timeouts and concurrency.
13
+ - **Never store access tokens, refresh tokens, API keys, secret keys, passwords, or other credentials in Redis as plaintext values.** Prefer caching a non-sensitive reference when possible. If a credential must be cached, encrypt before writing and decrypt only after reading. Require the established, vetted helper to authenticate the intended cache key, trusted tenant/account identity, and credential purpose as associated data (AAD), or provide equivalent purpose isolation for that exact context. Derive the same unambiguous, versioned context independently from the authorized request and computed key on both paths; never accept tenant or purpose solely from Redis or the ciphertext. Reject a mismatch without returning or using the credential. If the helper cannot bind this context, extend or wrap it safely before caching credentials. Keep encryption keys outside Redis in the existing secret-management system and support key rotation; do not invent cryptography.
14
+ - Protect Redis transport with TLS and restrict access using the deployment's network and least-privilege authentication controls. Encryption in transit or at rest does not replace application-level protection of sensitive cached values. Do not log cache values, credentials, connection strings, or decrypted payloads.
15
+ - Decide whether a Redis miss or outage should fall back to the source, fail closed, or surface an error based on the flow. Avoid a thundering herd on misses and keep any lock or refresh mechanism bounded. Add retries only when requested or required by existing architecture.
16
+ - Test expiration, invalidation, tenant isolation, concurrent cache misses, serialization changes, and Redis failure behavior where relevant. For credential caching, verify plaintext secrecy, decrypt failures, and key rotation. Copy a valid ciphertext to another cache key, tenant/account, or credential purpose and verify decryption or use fails closed in each case.
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: relational-database-dotnet
3
- description: Implement or review relational database access from .NET for PostgreSQL or Oracle. Use for ADO.NET/provider SQL, connections, transactions, commands, provider-specific mapping, or database retry decisions; compose with ef-core-safety when EF Core behavior is involved.
3
+ description: Use when implementing, reviewing, or troubleshooting .NET PostgreSQL or Oracle SQL, ADO.NET providers, parameterized queries, connections, transactions, mapping, or database performance. Compose with ef-core-safety for EF Core.
4
4
  ---
5
5
 
6
6
  # Relational database access for .NET
@@ -0,0 +1,14 @@
1
+ ---
2
+ name: retry-resilience
3
+ description: >
4
+ Use when the user explicitly requests retry behavior or when reviewing existing
5
+ retry and resilience logic for transient HTTP, database, Pub/Sub, or cloud API
6
+ dependencies. Do not introduce new retries unless requested or required by
7
+ existing architecture.
8
+ ---
9
+
10
+ # Retry and resilience in .NET
11
+
12
+ Inspect existing policies, timeouts, cancellation, and failure semantics first. Retry only transient failures on operations safe to repeat, or operations with a reliable idempotency key. Bound attempts, total time, and concurrency; use backoff with jitter where appropriate. Honor server retry guidance when supported.
13
+
14
+ Never retry validation failures or permanent authorization errors. Avoid layered retries that multiply calls and costs. Account for duplicate messages and ambiguous outcomes. Test the retry boundary with representative transient and permanent failures, and report the operational tradeoff.
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: task-router
3
- description: Classify a software request just enough to select a safe, proportionate workflow. Use at the start of implementation, investigation, review, testing, or Git work when the needed workflow is not already obvious.
3
+ description: Use only when the user explicitly requests help selecting a workflow or skill for a complex software task; choose relevant skills from their descriptions without implementing a custom skill router.
4
4
  ---
5
5
 
6
6
  # Task router
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: unit-testing
3
- description: Add or improve focused automated tests for production .NET behavior. Use for new or materially changed testable business logic and regression fixes; optional for explicitly identified POCs.
3
+ description: Use when adding or improving focused .NET unit tests for changed business behavior or regression fixes. Prefer parameterized cases and avoid trivial redundant tests that increase CI time.
4
4
  ---
5
5
 
6
6
  # .NET unit testing