diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index 4f5d1e5..6024f74 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -11,8 +11,8 @@ { "name": "dotnet-claude-kit", "source": "./", - "description": "45 skills (incl. 14 slash-command workflows), 10 agents, 10 rules, 5 templates, Roslyn MCP server for .NET 10 / C# 14", - "version": "0.10.0", + "description": "47 skills (incl. 16 slash-command workflows), 10 agents, 10 rules, 5 templates, 20-tool Roslyn MCP server for .NET 10 / C# 14", + "version": "0.11.0", "license": "MIT", "keywords": [ "dotnet", diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json index f075c3a..81d4a0c 100644 --- a/.claude-plugin/plugin.json +++ b/.claude-plugin/plugin.json @@ -1,8 +1,8 @@ { "name": "dotnet-claude-kit", "displayName": "dotnet Claude Kit", - "version": "0.10.0", - "description": "The definitive Claude Code companion for .NET developers. 45 skills (including 14 slash-command workflows), 10 agents, 10 rules, 5 templates, 15 MCP tools, and automation hooks for modern .NET 10 / C# 14.", + "version": "0.11.0", + "description": "The definitive Claude Code companion for .NET developers. 47 skills (including 16 slash-command workflows), 10 agents, 10 rules, 5 templates, 20 MCP tools, and automation hooks for modern .NET 10 / C# 14.", "author": { "name": "Mukesh Murugan", "url": "https://codewithmukesh.com" diff --git a/.claude/rules/agents.md b/.claude/rules/agents.md index 103c8a2..1f530b6 100644 --- a/.claude/rules/agents.md +++ b/.claude/rules/agents.md @@ -56,15 +56,3 @@ description: > - **DON'T** start implementation without checking if a relevant skill exists. Rationale: Re-discovering best practices wastes time when they are already codified. - -## Quick Reference - -| Need | Tool / Approach | -|---|---| -| Find where a type is defined | `find_symbol` | -| Understand who calls a method | `find_callers` | -| Check public API surface | `get_public_api` | -| Verify no regressions | `get_diagnostics` | -| Parallel research | Subagent | -| Architecture decision | Opus + specialist agent | -| Routine refactor | Sonnet | diff --git a/.claude/rules/coding-style.md b/.claude/rules/coding-style.md index ff3d9a9..8c22524 100644 --- a/.claude/rules/coding-style.md +++ b/.claude/rules/coding-style.md @@ -47,9 +47,6 @@ public sealed record Money(decimal Amount, string Currency); // DO List ids = [1, 2, 3]; int[] arr = [4, 5, 6]; - -// DON'T -var ids = new List { 1, 2, 3 }; ``` - **Pattern matching over if-else chains.** Switch expressions and `is` patterns are more readable and exhaustiveness-checked. @@ -62,12 +59,6 @@ var label = status switch OrderStatus.Shipped => "On the way", _ => "Unknown" }; - -// DON'T -string label; -if (status == OrderStatus.Pending) label = "Awaiting payment"; -else if (status == OrderStatus.Shipped) label = "On the way"; -else label = "Unknown"; ``` ## Naming and Modifiers diff --git a/.claude/rules/error-handling.md b/.claude/rules/error-handling.md index 3169f81..8d370eb 100644 --- a/.claude/rules/error-handling.md +++ b/.claude/rules/error-handling.md @@ -44,13 +44,3 @@ description: > - **DON'T** defensively validate inside internal/private methods. Rationale: Internal code should trust validated data. Double-validation adds noise without safety. - -## Quick Reference - -| Scenario | Approach | -|---|---| -| User input invalid | Result with Validation error | -| Entity not found | Result with NotFound error | -| Unhandled crash | IExceptionHandler middleware | -| External API failure | Catch specific exception, return Result | -| Concurrent update | Result with Conflict error | diff --git a/.claude/rules/git-workflow.md b/.claude/rules/git-workflow.md index ec514fb..fde4fc6 100644 --- a/.claude/rules/git-workflow.md +++ b/.claude/rules/git-workflow.md @@ -40,10 +40,7 @@ description: > ## Branch Safety - **DON'T** force-push to main or master. Ever. - Rationale: Force-push rewrites shared history and can destroy other contributors' work. - -- **DON'T** skip pre-commit hooks with `--no-verify`. - Rationale: Hooks catch real issues. Bypassing them pushes broken code upstream. + Rationale: Force-push rewrites shared history and can destroy other contributors' work. (Hook bypass rules live in `hooks.md`.) ## PR Process @@ -52,14 +49,3 @@ description: > - **DO** keep PRs focused on a single concern. Split large changes into stacked PRs. Rationale: Smaller PRs get faster, higher-quality reviews. - -## Quick Reference - -| Action | Convention | -|---|---| -| New feature | `feat: add order export endpoint` | -| Bug fix | `fix: prevent duplicate payments on retry` | -| Refactor | `refactor: extract pricing calculator from OrderService` | -| Tests only | `test: add edge cases for discount calculation` | -| Branch for feature | `feature/order-export` | -| Branch for fix | `fix/duplicate-payment` | diff --git a/.claude/rules/hooks.md b/.claude/rules/hooks.md index 5f642be..d0d0f29 100644 --- a/.claude/rules/hooks.md +++ b/.claude/rules/hooks.md @@ -35,12 +35,3 @@ description: > - **DO** wait for post-scaffold-restore to complete after `.csproj` changes before building. Rationale: NuGet restore must finish before the build can resolve dependencies. Building too early produces false errors. - -## Quick Reference - -| Hook | Correct Response | -|---|---| -| Post-edit format | Accept the changes | -| Pre-commit failure | Fix the issue, commit again | -| Post-test-analyze (manual pipe) | Read and act on insights | -| Post-scaffold-restore | Wait for completion before building | diff --git a/.claude/rules/performance.md b/.claude/rules/performance.md index 78a919f..04951a7 100644 --- a/.claude/rules/performance.md +++ b/.claude/rules/performance.md @@ -33,9 +33,6 @@ public sealed class AuditService(TimeProvider clock) { public DateTimeOffset Now => clock.GetUtcNow(); } - -// DON'T -var now = DateTime.UtcNow; ``` ## Resource Management @@ -53,13 +50,6 @@ var order = await cache.GetOrCreateAsync( $"order:{id}", async ct => await db.Orders.FindAsync([id], ct), cancellationToken: ct); - -// DON'T — manual cache-aside with no stampede protection -if (!memoryCache.TryGetValue(key, out var order)) -{ - order = await db.Orders.FindAsync(id); - memoryCache.Set(key, order, TimeSpan.FromMinutes(5)); -} ``` ## EF Core and Hot Paths diff --git a/.claude/rules/security.md b/.claude/rules/security.md index b1eaaa0..8e23e58 100644 --- a/.claude/rules/security.md +++ b/.claude/rules/security.md @@ -54,12 +54,7 @@ app.MapGet("/orders", GetOrders); ## Transport and Data Protection -- **Use HTTPS everywhere.** Enforce via HSTS in production. Redirect HTTP to HTTPS. No exceptions. - -```csharp -app.UseHsts(); -app.UseHttpsRedirection(); -``` +- **Use HTTPS everywhere.** Enforce via HSTS in production (`app.UseHsts()` + `app.UseHttpsRedirection()`). Redirect HTTP to HTTPS. No exceptions. - **Use Data Protection API for encrypting user data at rest.** Never roll your own encryption. The Data Protection API handles key rotation and algorithm selection correctly. - **CORS: explicit origins only, never wildcard in production.** `AllowAnyOrigin()` in production exposes your API to every domain on the internet. diff --git a/.claude/rules/testing.md b/.claude/rules/testing.md index 01519b9..59cc0a5 100644 --- a/.claude/rules/testing.md +++ b/.claude/rules/testing.md @@ -42,7 +42,7 @@ public async Task CreateOrder_ValidRequest_ReturnsCreated() var response = await client.PostAsJsonAsync("/orders", request); // Assert - response.StatusCode.Should().Be(HttpStatusCode.Created); + Assert.Equal(HttpStatusCode.Created, response.StatusCode); } ``` diff --git a/.codex/AGENTS.md b/.codex/AGENTS.md index bf08895..0305a51 100644 --- a/.codex/AGENTS.md +++ b/.codex/AGENTS.md @@ -25,7 +25,7 @@ Skills live in `skills//SKILL.md` and follow the Agent Skills open s api-versioning, architecture-advisor, aspire, authentication, caching, ci-cd, clean-architecture, configuration, ddd, dependency-injection, docker, ef-core, error-handling, httpclient-factory, logging, messaging, minimal-api, modern-csharp, openapi, opentelemetry, project-setup, project-structure, resilience, scalar, serilog, testing, vertical-slice, container-publish ### Workflow Skills (each registers a slash command and carries its methodology inline) -build-fix, checkpoint, code-review, de-sloppify, dotnet-init, health-check, migrate, plan, scaffold, security-scan, spec, tdd, verify, wrap-up +arch-check, build-fix, checkpoint, code-review, de-sloppify, dotnet-init, health-check, migrate, outdated, plan, scaffold, security-scan, spec, tdd, verify, wrap-up ### Workflow & Learning Skills convention-learner, workflow-mastery, instinct-system @@ -56,7 +56,7 @@ Always prefer MCP tools over reading full source files to conserve context windo ## Rules -Always-applied coding conventions live in `rules/dotnet/`: +Always-applied coding conventions live in `.claude/rules/`: - `coding-style.md` -- C# 14 style, naming, file organization - `architecture.md` -- Dependency direction, feature folders, data access @@ -67,3 +67,4 @@ Always-applied coding conventions live in `rules/dotnet/`: - `git-workflow.md` -- Conventional commits, atomic commits, branch safety - `agents.md` -- MCP-first tools, subagent routing, skill loading - `hooks.md` -- Format hooks, pre-commit, post-test analysis +- `packages.md` -- Latest stable NuGet versions, central package management diff --git a/.cursor/rules/dotnet-rules.md b/.cursor/rules/dotnet-rules.md index 32dbfce..49f377c 100644 --- a/.cursor/rules/dotnet-rules.md +++ b/.cursor/rules/dotnet-rules.md @@ -1,7 +1,7 @@ # .NET Development Rules (Cursor IDE) -> **Auto-generated** from `rules/dotnet/` directory in dotnet-claude-kit. -> Do not edit this file directly. Update the individual rule files in `rules/dotnet/` and regenerate. +> **Auto-generated** from the `.claude/rules/` directory in dotnet-claude-kit. +> Do not edit this file directly. Update the individual rule files in `.claude/rules/` and regenerate. --- diff --git a/.github/scripts/check_docs_consistency.py b/.github/scripts/check_docs_consistency.py new file mode 100644 index 0000000..78f2cdc --- /dev/null +++ b/.github/scripts/check_docs_consistency.py @@ -0,0 +1,155 @@ +#!/usr/bin/env python3 +"""Verify component counts claimed in docs match the actual repository contents. + +Ground truth (computed dynamically from the working tree): + skills : skills/*/SKILL.md directories + agents : agents/*.md files + rules : .claude/rules/*.md (plus rules/**/*.md if present) + templates : templates/*/ directories + mcp tools : [McpServerTool(...)] attribute occurrences in mcp/**/Tools/*.cs + workflows : rows of the "## Slash Commands" table in README.md + (each listed /name must also exist as a skill directory) + +Claim sources scanned: README.md, docs/shorthand-guide.md, +.claude-plugin/plugin.json, .claude-plugin/marketplace.json. +Every numeric claim found (e.g. "45 skills", "15 MCP tools", +"## Slash Commands (14)") must equal the computed actual. Sources without +counts pass trivially. +""" + +from __future__ import annotations + +import glob +import os +import re +import sys + +CLAIM_SOURCES = [ + "README.md", + "docs/shorthand-guide.md", + ".claude-plugin/plugin.json", + ".claude-plugin/marketplace.json", +] + +# category -> list of regexes whose single capture group is the claimed count +CLAIM_PATTERNS: dict[str, list[re.Pattern[str]]] = { + "skills": [re.compile(r"\b(\d+)\s+skills\b", re.I)], + "agents": [re.compile(r"\b(\d+)\s+(?:specialist\s+)?agents\b", re.I)], + "rules": [re.compile(r"\b(\d+)\s+rules\b", re.I)], + "templates": [re.compile(r"\b(\d+)\s+(?:project\s+)?templates\b", re.I)], + "mcp tools": [ + re.compile(r"\b(\d+)\s+(?:read-only\s+)?(?:Roslyn\s+)?MCP\s+tools\b", re.I), + re.compile(r"\((\d+)\s+tools\)", re.I), + ], + "workflows": [ + re.compile(r"\b(\d+)\s+slash[- ]command", re.I), + re.compile(r"^##\s+Slash\s+Commands\s+\((\d+)\)", re.I), + ], +} + +MCP_TOOL_ATTR = re.compile(r"\[\s*McpServerTool\s*[(,\]]") +SLASH_HEADING = re.compile(r"^##\s+Slash\s+Commands\b", re.I) +SLASH_ROW = re.compile(r"^\|\s*`/([A-Za-z0-9_-]+)`") + + +def count_mcp_tools() -> int: + total = 0 + for path in glob.glob("mcp/**/Tools/*.cs", recursive=True): + with open(path, encoding="utf-8") as f: + total += len(MCP_TOOL_ATTR.findall(f.read())) + return total + + +def slash_commands_from_readme(errors: list[str]) -> int | None: + """Return the number of /commands listed in README's Slash Commands table, + verifying each maps to an existing skill directory.""" + if not os.path.isfile("README.md"): + errors.append("README.md not found") + return None + with open("README.md", encoding="utf-8") as f: + lines = f.readlines() + names: list[str] = [] + in_section = False + for line in lines: + if SLASH_HEADING.match(line): + in_section = True + continue + if in_section and line.startswith("## "): + break + if in_section: + match = SLASH_ROW.match(line) + if match: + names.append(match.group(1)) + if not in_section: + return None + for name in names: + if not os.path.isfile(f"skills/{name}/SKILL.md"): + errors.append( + f"README.md slash-command table lists /{name} " + f"but skills/{name}/SKILL.md does not exist" + ) + return len(names) + + +def compute_actuals(errors: list[str]) -> dict[str, int | None]: + rules = set(glob.glob(".claude/rules/*.md")) | set( + glob.glob("rules/**/*.md", recursive=True) + ) + return { + "skills": len(glob.glob("skills/*/SKILL.md")), + "agents": len(glob.glob("agents/*.md")), + "rules": len(rules), + "templates": len([d for d in glob.glob("templates/*") if os.path.isdir(d)]), + "mcp tools": count_mcp_tools(), + "workflows": slash_commands_from_readme(errors), + } + + +def scan_claims(actuals: dict[str, int | None], errors: list[str]) -> int: + checked = 0 + for source in CLAIM_SOURCES: + if not os.path.isfile(source): + print(f"note: {source} not found, skipping") + continue + with open(source, encoding="utf-8") as f: + lines = f.readlines() + for lineno, line in enumerate(lines, start=1): + for category, patterns in CLAIM_PATTERNS.items(): + for pattern in patterns: + for match in pattern.finditer(line): + checked += 1 + claimed = int(match.group(1)) + actual = actuals[category] + if actual is None: + errors.append( + f"{source}:{lineno} claims {claimed} {category} but " + "the actual count could not be computed " + "(no Slash Commands table found in README.md)" + ) + elif claimed != actual: + errors.append( + f"{source}:{lineno} claims {claimed} {category}, " + f"actual is {actual} — '{match.group(0).strip()}'" + ) + return checked + + +def main() -> int: + errors: list[str] = [] + actuals = compute_actuals(errors) + print("actual counts:") + for category, value in actuals.items(): + print(f" {category}: {value if value is not None else 'n/a'}") + checked = scan_claims(actuals, errors) + print(f"checked {checked} numeric claim(s) across {len(CLAIM_SOURCES)} source(s)") + for error in errors: + print(f"ERROR: {error}") + if errors: + print(f"{len(errors)} docs-consistency error(s) found") + return 1 + print("all documented counts match the repository contents") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/.github/scripts/check_frontmatter.py b/.github/scripts/check_frontmatter.py new file mode 100644 index 0000000..46c3e4c --- /dev/null +++ b/.github/scripts/check_frontmatter.py @@ -0,0 +1,133 @@ +#!/usr/bin/env python3 +"""Validate YAML frontmatter schemas for plugin agents and skills. + +Usage: check_frontmatter.py {agents|skills} + +Agents (agents/*.md) — strict schema: + required : name, description + optional : model, memory, tools, disallowedTools, isolation, maxTurns, + effort, color + forbidden: permissionMode, hooks, mcpServers (reserved for user/project-level + agents; plugin agents must not declare them) + model : tier alias only (fable/opus/sonnet/haiku), never a pinned version + +Skills (skills/*/SKILL.md) — light schema: + required : name, description + optional : disable-model-invocation (boolean) is explicitly accepted; + other keys are not rejected here to keep the check forward-compatible +""" + +from __future__ import annotations + +import glob +import re +import sys + +import yaml + +AGENT_REQUIRED = {"name", "description"} +AGENT_OPTIONAL = { + "model", + "memory", + "tools", + "disallowedTools", + "isolation", + "maxTurns", + "effort", + "color", +} +AGENT_FORBIDDEN = {"permissionMode", "hooks", "mcpServers"} +MODEL_ALIASES = {"fable", "opus", "sonnet", "haiku"} + +FRONTMATTER_RE = re.compile(r"\A---[ \t]*\n(.*?)\n---[ \t]*(?:\n|\Z)", re.DOTALL) + + +def load_frontmatter(path: str): + with open(path, encoding="utf-8") as f: + text = f.read() + match = FRONTMATTER_RE.match(text) + if match is None: + return None, f"{path}: missing YAML frontmatter block (--- ... ---)" + try: + data = yaml.safe_load(match.group(1)) + except yaml.YAMLError as exc: + return None, f"{path}: frontmatter is not valid YAML: {exc}" + if not isinstance(data, dict): + return None, f"{path}: frontmatter must be a YAML mapping" + return data, None + + +def check_agents() -> list[str]: + errors: list[str] = [] + files = sorted(glob.glob("agents/*.md")) + if not files: + return ["no agent files found under agents/"] + for path in files: + fm, err = load_frontmatter(path) + if err: + errors.append(err) + continue + keys = set(fm) + for key in sorted(AGENT_REQUIRED - keys): + errors.append(f"{path}: missing required frontmatter field '{key}'") + for key in sorted(keys & AGENT_FORBIDDEN): + errors.append( + f"{path}: field '{key}' is reserved for user/project-level agents " + "and must not appear in plugin agents" + ) + unknown = keys - AGENT_REQUIRED - AGENT_OPTIONAL - AGENT_FORBIDDEN + for key in sorted(unknown): + allowed = ", ".join(sorted(AGENT_REQUIRED | AGENT_OPTIONAL)) + errors.append( + f"{path}: unknown frontmatter field '{key}' (allowed: {allowed})" + ) + model = fm.get("model") + if model is not None and model not in MODEL_ALIASES: + errors.append( + f"{path}: model '{model}' must be a tier alias " + f"({'/'.join(sorted(MODEL_ALIASES))}), never a pinned version ID" + ) + print(f"checked {len(files)} agent(s)") + return errors + + +def check_skills() -> list[str]: + errors: list[str] = [] + files = sorted(glob.glob("skills/*/SKILL.md")) + if not files: + return ["no SKILL.md files found under skills/"] + for path in files: + fm, err = load_frontmatter(path) + if err: + errors.append(err) + continue + for key in ("name", "description"): + if key not in fm: + errors.append(f"{path}: missing required frontmatter field '{key}'") + # disable-model-invocation is an accepted optional field — validate its + # type but never reject its presence. + dmi = fm.get("disable-model-invocation") + if dmi is not None and not isinstance(dmi, bool): + errors.append( + f"{path}: 'disable-model-invocation' must be a boolean, got {dmi!r}" + ) + print(f"checked {len(files)} skill(s)") + return errors + + +def main() -> int: + if len(sys.argv) != 2 or sys.argv[1] not in ("agents", "skills"): + print("usage: check_frontmatter.py {agents|skills}", file=sys.stderr) + return 2 + errors = check_agents() if sys.argv[1] == "agents" else check_skills() + for error in errors: + print(f"ERROR: {error}") + if errors: + print(f"{len(errors)} frontmatter error(s) found") + return 1 + print("frontmatter validated successfully") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/.github/scripts/test-hooks.sh b/.github/scripts/test-hooks.sh new file mode 100644 index 0000000..4554049 --- /dev/null +++ b/.github/scripts/test-hooks.sh @@ -0,0 +1,150 @@ +#!/usr/bin/env bash +# CI behavior tests for hooks/*.sh under Git Bash (runs on windows-latest). +# +# Exercises realistic Claude Code PreToolUse/PostToolUse JSON payloads — +# including Windows backslash paths — and asserts the hooks terminate +# promptly with the intended exit codes: +# +# post-edit-format.sh : must not hang on Windows paths (drive-root walk guard) +# pre-bash-guard.sh : benign command -> exit 0 +# force push -> exit 2, reason on stderr +# payload that merely MENTIONS "reset --hard" in a +# string field -> exit 0 (regression test for the +# no-jq raw-payload over-blocking bug) +set -u + +cd "$(dirname "$0")/../.." || exit 1 + +HOOK_TIMEOUT="${HOOK_TIMEOUT:-30}" +FAILURES=0 +WORKDIR=$(mktemp -d) +trap 'rm -rf "$WORKDIR"' EXIT + +pass() { echo "PASS: $1"; } +fail() { echo "FAIL: $1"; FAILURES=$((FAILURES + 1)); } + +# Portable timeout: prefer coreutils timeout (shipped with Git for Windows), +# fall back to a background watchdog. Returns 124+ when the command was killed. +run_with_timeout() { + local limit="$1" + shift + if command -v timeout >/dev/null 2>&1; then + timeout --kill-after=5 "$limit" "$@" + return $? + fi + "$@" & + local pid=$! + ( + sleep "$limit" + kill -9 "$pid" 2>/dev/null + ) & + local watchdog=$! + wait "$pid" 2>/dev/null + local rc=$? + kill "$watchdog" 2>/dev/null || true + wait "$watchdog" 2>/dev/null || true + return "$rc" +} + +# Run a hook with a JSON payload on stdin, isolated from env-var shortcuts. +# Usage: run_hook