基于 SOC 职业分类
用 Codex 或 Claude 帮你安装 复制这段 Prompt,粘贴到 Codex、Claude 或其他助手里,让它检查 Skill 页面并帮你完成安装。
直接命令不会经过审查 Prompt;运行前请先检查来源。
npx skills add https://github.com/hpsgd/turtlestack --skill write-endpoint命令会保持在同一行。复制前请横向滚动并检查完整内容。
想先保存到本地?可下载 SkillsMP 当前能够提供的文件。
正在显示 SKILL.md
Review staged or recent changes — native Claude Code review for mechanics, layered with team conventions and the team verdict contract
Perform a security-focused audit of code changes or a specific area of the codebase.
Propose a change to a marketplace repo based on learned patterns — new rules, updated skills, evolved regex patterns. Infers which upstream marketplace the learning belongs to, confirms with the user, then creates a branch, applies changes, shows diff for review, and raises a PR on approval. Use when patterns have enough evidence to share upstream.
| name | write-endpoint |
| description | Write a Wolverine HTTP endpoint with pre-conditions, handler, and tests. |
| argument-hint | [endpoint description, e.g. 'GET /api/sources/{id}/crawls'] |
| user-invocable | true |
| allowed-tools | Read, Write, Edit, Bash, Glob, Grep |
| paths | ["**/*.cs"] |
Write a Wolverine endpoint for $ARGUMENTS.
Before writing the endpoint:
Read existing endpoints — find the nearest similar endpoint and match its patterns
find . -name "*.cs" -path "*/Endpoints/*" | head -20
grep -rn "WolverineGet\|WolverinePost\|WolverinePut\|WolverineDelete\|WolverinePatch" --include="*.cs" | head -20
Identify the aggregate — which Marten aggregate does this endpoint operate on?
Identify the URL hierarchy — trace the entity ownership chain to the root
Check for existing commands/events — reuse existing message types where appropriate
URLs MUST mirror entity ownership. No flat top-level listings of child resources.
GET /api/sources → List sources (paginated)
POST /api/sources → Create source
GET /api/sources/{sourceId} → Get source
PATCH /api/sources/{sourceId} → Update source
DELETE /api/sources/{sourceId} → Delete source
GET /api/sources/{sourceId}/crawls → List crawls for source
POST /api/sources/{sourceId}/crawls → Create crawl for source
GET /api/sources/{sourceId}/crawls/{crawlId} → Get specific crawl
Rules:
/sources, not /source{sourceId}, not {id} (disambiguates in nested routes)POST /sources/{id}/crawls not POST /sources/{id}/triggerCrawlpublic static class CreateSourceEndpoint
{
// Pre-condition: validate, load dependencies, check authorisation
// Returns ProblemDetails to short-circuit with an error response
// Returns null to proceed to Handle
public static async Task<ProblemDetails?> LoadAsync(
CreateSourceCommand command,
IDocumentSession session,
CancellationToken ct)
{
// Validate business rules that require database access
var exists = await session.Query<Source>()
.AnyAsync(s => s.Name == command.Name, ct);
if (exists)
{
return new ProblemDetails
{
Title = "Source already exists",
Detail = $"A source with name '{command.Name}' already exists.",
Status = 409
};
}
return null; // Proceed to Handle
}
// Handler: pure business logic, returns events as cascading messages
[WolverinePost("/api/sources")]
public static SourceCreated Handle(CreateSourceCommand command)
{
var source = new Source
{
Id = CombGuidIdGeneration.NewGuid(),
Name = command.Name,
Url = command.Url,
CreatedAt = DateTimeOffset.UtcNow
};
return new SourceCreated(source.Id, source.Name);
}
}
LoadAsync rules:
Task<ProblemDetails?> — null means "proceed", non-null means "stop with this error"IDocumentSession, CancellationToken, and any services needed for validationHandle rules:
object? for polymorphic cascade)public static class GetSourceEndpoint
{
[WolverineGet("/api/sources/{sourceId}")]
public static async Task<IResult> Handle(
Guid sourceId,
IQuerySession session,
CancellationToken ct)
{
var source = await session.LoadAsync<Source>(sourceId, ct);
return source is not null
? Results.Ok(source.ToResponse())
: Results.NotFound();
}
}
Rules:
IQuerySession (read-only) not IDocumentSession (read-write) for queriesIResult to control HTTP status codespublic static class ListSourceCrawlsEndpoint
{
private const int MaxPageSize = 100;
private static readonly HashSet<string> AllowedSortFields = new(StringComparer.OrdinalIgnoreCase)
{ "name", "createdAt" };
private static readonly HashSet<string> AllowedDirections = new(StringComparer.OrdinalIgnoreCase)
{ "asc", "desc" };
public record ListCrawlsRequest(
Guid SourceId,
int Page = 1,
int Size = 25,
string? Sort = "createdAt",
string? Dir = "desc",
string? Q = null);
// Pre-condition: validate sort/dir against allowlist, check parent source exists.
public static async Task<ProblemDetails?> LoadAsync(
[AsParameters] ListCrawlsRequest request,
HttpContext http,
IQuerySession session,
CancellationToken ct)
{
if (request.Sort is not null && !AllowedSortFields.Contains(request.Sort))
return ProblemDetails
{
Status = ,
Title = ,
Detail = ,
Instance = http.Request.Path
};
(request.Dir && !AllowedDirections.Contains(request.Dir))
ProblemDetails
{
Status = ,
Title = ,
Detail = ,
Instance = http.Request.Path
};
sourceExists = session.Query<Source>()
.AnyAsync(s => s.Id == request.SourceId, ct);
(!sourceExists)
ProblemDetails
{
Status = ,
Title = ,
Detail = ,
Instance = http.Request.Path
};
;
}
[]
Task<PagedResult<CrawlResponse>> Handle(
[] ListCrawlsRequest request,
IQuerySession session,
CancellationToken ct)
{
size = Math.Min(request.Size, MaxPageSize);
query = session.Query<Crawl>()
.Where(c => c.SourceId == request.SourceId);
(!.IsNullOrWhiteSpace(request.Q))
{
query = query.Where(c => c.Name.MatchesSql(, request.Q));
}
query = request.Sort?.ToLowerInvariant()
{
=> request.Dir ==
? query.OrderBy(c => c.Name)
: query.OrderByDescending(c => c.Name),
_ => request.Dir ==
? query.OrderBy(c => c.CreatedAt)
: query.OrderByDescending(c => c.CreatedAt)
};
totalItems = query.CountAsync(ct);
items = query
.Skip((request.Page - ) * size)
.Take(size)
.ToListAsync(ct);
PagedResult<CrawlResponse>(
items.Select(c => c.ToResponse()).ToList(),
request.Page,
size,
totalItems);
}
}
List endpoint rules:
page, size, sort, dir, q (text search)PagedResult<T> with items, page, size, totalItems, totalPages (derived from totalItems / size)createdAt desc for time-based, name asc for alphabetical)LoadAsync against an allowlist — return 400 ProblemDetails on invalid values, never forward arbitrary strings to the ORMMath.Min(request.Size, MaxPageSize) — never trust the client valueLoadAsync returning 404 ProblemDetails with Instance set to the request path — a list query for a non-existent parent is a 404, not an empty arrayq) uses case-insensitive substring matching against documented fields (e.g. Name) via Marten ILIKE / MatchesSql, not the default case-sensitive string.Contains// Command — what the caller wants to happen
public record CreateSourceCommand(
string Name,
string Url);
// Event — what happened (past tense, immutable)
public record SourceCreated(
Guid SourceId,
string Name);
// Response DTO — what the caller sees
public record SourceResponse(
Guid Id,
string Name,
string Url,
DateTimeOffset CreatedAt,
DateTimeOffset? LastUpdatedAt);
Rules:
CreateSource, UpdateCrawlSettings)SourceCreated, CrawlSettingsUpdated)LastUpdatedAt on every response for optimistic concurrencypublic static class UpdateSourceEndpoint
{
public static async Task<ProblemDetails?> LoadAsync(
UpdateSourceCommand command,
IDocumentSession session,
CancellationToken ct)
{
var source = await session.LoadAsync<Source>(command.SourceId, ct);
if (source is null)
return new ProblemDetails { Status = 404, Title = "Source not found" };
if (source.LastUpdatedAt != command.LastUpdatedAt)
return new ProblemDetails
{
Status = 409,
Title = "Conflict",
Detail = "The resource was modified by another request. Please re-fetch and retry."
};
return null;
}
[WolverinePatch("/api/sources/{sourceId}")]
public static SourceUpdated Handle(UpdateSourceCommand command, Source source)
{
// Apply changes using RFC 7396 merge semantics
if (command.Name is not null) source.Name = command.Name;
if (command.Url is not null) source.Url = command.Url;
source.LastUpdatedAt = DateTimeOffset.UtcNow;
return new SourceUpdated(source.Id);
}
}
public class WhenCreatingASource
{
[Fact]
public void it_returns_a_source_created_event()
{
// Arrange
var command = new CreateSourceCommand("Test Source", "https://example.com");
// Act
var result = CreateSourceEndpoint.Handle(command);
// Assert
result.ShouldNotBeNull();
result.Name.ShouldBe("Test Source");
}
}
public class CreateSourceIntegrationTest : IntegrationContext
{
[Fact]
public async Task it_creates_a_source_and_returns_201()
{
// Arrange
var command = new CreateSourceCommand("Test Source", "https://example.com");
// Act
var result = await Host.Scenario(s =>
{
s.Post.Json(command).ToUrl("/api/sources");
s.StatusCodeShouldBe(201);
});
// Assert
var response = result.ReadAsJson<SourceResponse>();
response.ShouldNotBeNull();
response.Name.ShouldBe("Test Source");
}
[Fact]
public async Task it_returns_409_when_source_name_already_exists()
{
// Arrange — create existing source
await Host.Scenario(s =>
{
s.Post.Json(new CreateSourceCommand("Duplicate", "https://a.com")).ToUrl("/api/sources");
s.StatusCodeShouldBe(201);
});
// Act — try to create another with same name
await Host.Scenario(s =>
{
s.Post.Json(new CreateSourceCommand("Duplicate", "https://b.com")).ToUrl("/api/sources");
s.StatusCodeShouldBe(409);
});
}
}
Testing rules:
WhenCreatingASource, GivenAnExistingSourceSubstitute.For<T>()ShouldBe, ShouldNotBeNull, ShouldThrowIntegrationContext boots a real database container per test fixture — no shared dev DB, no in-memory provider)Evidence of passing — paste actual command and exit code into the PR description:
$ dotnet test ./tests/Sources.Tests/Sources.Tests.csproj
Passed! - Failed: 0, Passed: 14, Skipped: 0, Total: 14
$ echo "Exit: $?"
Exit: 0
/crawls/{id} without the parent /sources/{sourceId}/crawls/{id}lastUpdatedAt allows silent overwritesDeliver:
/dotnet-developer:write-handler — endpoints delegate to handlers. Write the endpoint first (HTTP contract), then the handler (business logic).