Want to build an extension to Spec Kit? This comprehensive guide walks you through everything you need: a ready-to-drop extension layout, a complete extension.yml manifest, a slash-command file, a lifecycle hook that runs a C# helper, a full C# console app (source + csproj), install scripts, CI to build multi-platform artifacts and package the extension, plus troubleshooting, testing, and security advice.

What you’ll get (artifacts shown in this guide)
- A consistent extension folder layout you can copy into a project-level .specify/extensions/ directory for local testing. (If your environment uses a different extension directory, replace .specify accordingly.)
- Full extension.yml manifest.
- A slash command file: addon/commands/speckit.extn.myfeature.md.
- A lifecycle hook descriptor: addon/hooks/after-plan.yml and platform run wrappers.
- A small C# console app (MyFeatureRunner) that reads a spec path and writes structured JSON to stdout.
- Install scripts (install.sh, install.ps1) and OS wrappers (run.sh/run.ps1) to select the right binary.
- A GitHub Actions workflow that builds platform binaries and packages a ZIP for publishing.
- Tests (xUnit) that exercise the analyzer logic (no Python anywhere).
Step 0 — conventions & a note on paths
- I’ll use “Spec Kit” consistently here and show the local extension install path as .specify/extensions/ (this is the common convention in community examples). If your tooling uses a different folder, replace .specify accordingly.
- Slash command namespace convention: /speckit.extn..* — follow this for discoverability.
- Hooks use placeholders like {{spec.path}}; confirm your Spec Kit version’s exact interpolation tokens and adapt if necessary.
Step 1 — pick scope and design
Decide the minimal valuable feature for your first release. Example here:
- Add a slash command /speckit.extn.myfeature that runs an analysis on the current spec and returns structured JSON with a summary and issues. Also register an after-plan lifecycle hook that runs the same analyzer automatically when planning completes.
Define:
- Inputs: spec path (string), format (json|text), output path (optional).
- Outputs: structured JSON (summary, issues[]), exit codes.
- Failure modes: missing spec, parse errors, internal errors — map to clear exit codes and clear stderr messages.
Design outcome: a C# console app MyFeatureRunner that accepts –spec and writes JSON to stdout or an output file. Hooks call the executable with placeholders.
Step 2 — folder layout (concrete)
Create this structure (top-level folder is the extension package root):
com.yourorg.myfeature/
extension.yml
README.md
addon/
commands/
speckit.extn.myfeature.md
hooks/
after-plan.yml
templates/
mytemplate.txt
bin/
linux/
MyFeatureRunner <– published single-file linux binary
macos/
MyFeatureRunner <– published macos binary
windows/
MyFeatureRunner.exe <– published windows exe
run.sh <– wrapper selecting right binary on Unix
run.ps1 <– wrapper for Windows
src/
MyFeatureRunner/ <– dotnet project (Program.cs, Analyzer.cs, MyFeatureRunner.csproj)
tests/
MyFeatureRunner.Tests/ <– xUnit tests
examples/
sample-spec.md
install.sh
install.ps1
Notes:
- Place platform-specific published binaries under addon/bin//. Include wrapper scripts (run.sh, run.ps1) that forward args to the right binary.
- The extension.yml manifest (next) will map addon/ directories into the install target.
Step 3 — extension manifest (full example)
Put this file at the extension root as extension.yml:
name: com.yourorg.myfeature
version: 0.1.0
description: “Adds /speckit.extn.myfeature — analyzes a spec and returns structured JSON with issues.”
authors:
- name: Your Name
email: you@example.com
license: MIT
tags: - spec-analysis
- speckit
install:
files:- src: addon/commands/
dest: commands/ - src: addon/hooks/
dest: hooks/ - src: addon/templates/
dest: templates/ - src: addon/bin/
dest: bin/
hooks:
- src: addon/commands/
- name: after-plan
file: addon/hooks/after-plan.yml
Explanation:
- install.files tells the Spec Kit installer how to copy files into the runtime extension directory. Keep the paths relative to the extension archive root.
- hooks are entry points to a hook descriptor file (next).
Step 4 — slash command file (full example)
File: addon/commands/speckit.extn.myfeature.md
Description: Analyze the given Spec file for structural issues and return a JSON report with “summary” and “issues”.
Inputs:
- spec-path: path to the spec file. Defaults to the current spec in the workspace if omitted.
- format: “json” (default) or “text”.
- output: optional path to write the JSON report.
Outputs:
- result (JSON):
{
“summary”: “string”,
“issues”: [
{
“line”: 12,
“severity”: “warning”,
“ruleId”: “SP-001”,
“message”: “Example issue message”
}
]
}
Examples:
- /speckit.extn.myfeature spec-path=specs/login.spec format=json
- /speckit.extn.myfeature format=text
Behavior:
- When invoked, the agent will run the extension’s hook or run wrapper that calls the C# CLI with –spec . The CLI emits structured JSON to stdout. The agent should present JSON results or a text summary as requested.
Notes:
- Keep examples explicit and provide the JSON schema so integrators know how to parse the result.
Step 5 — hook descriptor (full example)
File: addon/hooks/after-plan.yml
hook: after-plan
run: ./bin/run.sh
args:
- –spec
- “{{spec.path}}”
- –output
- “{{workspace}}/.specify/extensions/com.yourorg.myfeature/latest-report.json”
env:
LOG_LEVEL: info
Notes:
- run points to the wrapper script in addon/bin/ that selects the correct binary for the host OS. This simplifies cross-platform support.
- args include placeholders. Confirm the exact placeholder names in your Spec Kit version; many installations support {{spec.path}} and {{workspace}} or similar fields.
Step 6 — C# helper (full source example)
We’ll implement a small console app that performs simple analysis: counts characters and looks for TODO comments as “issues”. It’s intentionally simple but structured to be extended.
Project: src/MyFeatureRunner/MyFeatureRunner.csproj Exe net8.0 enable enable
Program.cs (src/MyFeatureRunner/Program.cs)
using System.Text.Json;
using System.Text.Json.Serialization;
public static class Program
{
public static int Main(string[] args)
{
var parsed = CliArgs.Parse(args);
if (!parsed.IsValid)
{
Console.Error.WriteLine(parsed.ErrorMessage);
return parsed.ExitCode;
}
try
{
var report = Analyzer.AnalyzeFile(parsed.SpecPath);
var options = new JsonSerializerOptions { WriteIndented = true };
string json = JsonSerializer.Serialize(report, options);
if (!string.IsNullOrEmpty(parsed.Output))
{
File.WriteAllText(parsed.Output, json);
Console.WriteLine($"Report written to {parsed.Output}");
}
else
{
Console.WriteLine(json);
}
return 0;
}
catch (FileNotFoundException ex)
{
Console.Error.WriteLine(ex.Message);
return 3;
}
catch (Exception ex)
{
Console.Error.WriteLine($"Internal error: {ex.Message}");
return 10;
}
}
}
CliArgs helper (embedded in same file or separate file for clarity):
internal sealed class CliArgs
{
public string SpecPath { get; init; } = string.Empty;
public string Output { get; init; } = string.Empty;
public bool IsValid { get; init; }
public string ErrorMessage { get; init; } = string.Empty;
public int ExitCode { get; init; }
public static CliArgs Parse(string[] args)
{
if (args.Length == 0)
return new CliArgs { IsValid = false, ErrorMessage = "Usage: MyFeatureRunner --spec <path> [--output <path>]", ExitCode = 2 };
var dict = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
for (int i = 0; i < args.Length; i++)
{
if (args[i].StartsWith("--") && i + 1 < args.Length)
{
dict[args[i]] = args[i + 1];
i++;
}
}
if (!dict.TryGetValue("--spec", out var spec) || string.IsNullOrWhiteSpace(spec))
return new CliArgs { IsValid = false, ErrorMessage = "Missing required --spec <path>", ExitCode = 2 };
dict.TryGetValue("--output", out var output);
return new CliArgs { IsValid = true, SpecPath = spec!, Output = output ?? string.Empty };
}
}
Analyzer (src/MyFeatureRunner/Analyzer.cs)
using System.Text.Json.Serialization;
public static class Analyzer
{
public static AnalysisReport AnalyzeFile(string path)
{
if (!File.Exists(path))
throw new FileNotFoundException($"Spec not found: {path}", path);
var text = File.ReadAllText(path);}
var lines = text.Split(new[] { '\r', '\n' }, StringSplitOptions.RemoveEmptyEntries);
var issues = new List<Issue>();
for (int i = 0; i < lines.Length; i++)
{
var line = lines[i];
if (line.Contains("TODO", StringComparison.OrdinalIgnoreCase))
{
issues.Add(new Issue
{
Line = i + 1,
Severity = "warning",
RuleId = "SP-001",
Message = "Found TODO in spec; consider clarifying acceptance criteria."
});
}
}
return new AnalysisReport
{
Summary = $"Spec has {text.Length} chars, {lines.Length} non-empty lines, {issues.Count} issues.",
Issues = issues
};
}
public class AnalysisReport
{
[JsonPropertyName("summary")]
public string Summary { get; set; } = string.Empty;
[JsonPropertyName("issues")]
public List<Issue> Issues { get; set; } = new();
}
public class Issue
{
[JsonPropertyName("line")]
public int Line { get; set; }
[JsonPropertyName("severity")]
public string Severity { get; set; } = string.Empty;
[JsonPropertyName("ruleId")]
public string RuleId { get; set; } = string.Empty;
[JsonPropertyName("message")]
public string Message { get; set; } = string.Empty;
}
Design notes:
- Analyzer.AnalyzeFile is pure and easily testable. Keep the CLI thin.
- Exit codes:
- 0 success
- 2 argument error / usage
- 3 not found (spec path)
- 10 internal error
Step 7 — unit tests (xUnit)
Create src/MyFeatureRunner.Tests/MyFeatureRunner.Tests.csproj and a simple test that calls Analyzer directly (no process spawning), ensuring repeatable logic.
Sample test (src/MyFeatureRunner.Tests/AnalyzerTests.cs)
using Xunit;
using System.IO;
using System.Text.Json;
public class AnalyzerTests
{
[Fact]
public void AnalyzeFile_ReturnsIssuesForTODO()
{
var tmp = Path.GetTempFileName();
File.WriteAllText(tmp, "Line one\nTODO: add acceptance\nAnother line\n");
var report = Analyzer.AnalyzeFile(tmp);
Assert.Contains("Spec has", report.Summary);
Assert.Single(report.Issues);
Assert.Equal("SP-001", report.Issues[0].RuleId);
}
}
Step 8 — run wrappers and permissions
Create addon/bin/run.sh (Unix wrapper):
#!/usr/bin/env bash
set -euo pipefail
ROOT="$(cd "$(dirname "$0")" && pwd)"
PLATFORM_BIN=""
if [[ "$(uname -s)" == "Linux" ]]; then
PLATFORM_BIN="$ROOT/linux/MyFeatureRunner"
elif [[ "$(uname -s)" == "Darwin" ]]; then
PLATFORM_BIN="$ROOT/macos/MyFeatureRunner"
else
PLATFORM_BIN="$ROOT/windows/MyFeatureRunner.exe"
fi
exec "$PLATFORM_BIN" "$@"
Make it executable: chmod +x addon/bin/run.sh.
Windows wrapper addon/bin/run.ps1 (PowerShell):
param([Parameter(ValueFromRemainingArguments=$true)]$Args)
$root = Split-Path -Parent $MyInvocation.MyCommand.Path
$os = (Get-CimInstance -ClassName Win32_OperatingSystem).Caption
$exe = Join-Path $root "windows\MyFeatureRunner.exe"
& $exe @Args
Step 9 — installer scripts
install.sh:
#!/usr/bin/env bash
set -euo pipefail
EXT_ID="com.yourorg.myfeature"
DEST=".specify/extensions/$EXT_ID"
mkdir -p "$DEST"
cp -r addon "$DEST/"
cp extension.yml "$DEST/"
cp README.md "$DEST/"
echo "Installed extension to $DEST"
install.ps1 (PowerShell):
param([string]$Dest = ".specify/extensions/com.yourorg.myfeature")
New-Item -ItemType Directory -Force -Path $Dest | Out-Null
Copy-Item -Path "addon*" -Destination $Dest -Recurse -Force
Copy-Item -Path "extension.yml" -Destination $Dest -Force
Copy-Item -Path "README.md" -Destination $Dest -Force
Write-Host "Installed extension to $Dest"
Step 10 — test locally (step-by-step)
- In a Spec Kit project:
mkdir -p .specify/extensions
cp -r com.yourorg.myfeature .specify/extensions/ - Confirm files:
ls .specify/extensions/com.yourorg.myfeature - Run the analyzer directly:
dotnet run –project src/MyFeatureRunner — –spec examples/sample-spec.md Expected JSON printed or “Report written to ” if –output provided. - Trigger the after-plan hook via your normal Spec Kit flow (specify → plan) and confirm:
- Hook runs and writes latest-report.json in the extension folder (or check STDOUT in logs).
- If hook failed, inspect agent logs or the extension’s stderr captured by the CLI.
Step 11 — publishing & packaging with CI (GitHub Actions)
A sample workflow .github/workflows/publish.yml that:
- Builds and publishes single-file binaries for multiple runtimes
- Packages the extension folder into a zip
- Uploads the zip as an artifact (or creates a release)
Sample YAML (abridged for clarity):
name: Build and Package Extension
on:
push:
tags:
- 'v*..'
jobs:
build:
runs-on: ubuntu-latest
strategy:
matrix:
runtime: [linux-x64, linux-arm64, osx-arm64, win-x64]
steps:
- uses: actions/checkout@v4
- name: Setup .NET
uses: actions/setup-dotnet@v3
with:
dotnet-version: '8.0.x'
- name: Publish ${{ matrix.runtime }}
run: |
dotnet publish src/MyFeatureRunner -c Release -r ${{ matrix.runtime }}
-p:PublishSingleFile=true -p:PublishTrimmed=true -o ./addon/bin/${{ matrix.runtime }}
- name: Organize binaries
run: |
# move to the platform folder expected by the extension, e.g. linux->linux, osx->macos, win->windows
mkdir -p addon/bin/linux addon/bin/macos addon/bin/windows
if [[ "${{ matrix.runtime }}" == linux* ]]; then mv ./addon/bin/${{ matrix.runtime }}/MyFeatureRunner ./addon/bin/linux/MyFeatureRunner; fi
if [[ "${{ matrix.runtime }}" == osx* ]]; then mv ./addon/bin/${{ matrix.runtime }}/MyFeatureRunner ./addon/bin/macos/MyFeatureRunner; fi
if [[ "${{ matrix.runtime }}" == win* ]]; then mv ./addon/bin/${{ matrix.runtime }}/MyFeatureRunner.exe ./addon/bin/windows/MyFeatureRunner.exe; fi
- name: Zip extension
if: ${{ matrix.runtime == 'linux-x64' }}
run: |
zip -r com.yourorg.myfeature-${{ github.ref_name }}.zip extension.yml addon README.md
- name: Upload package
uses: actions/upload-artifact@v4
with:
name: extension-zip
path: com.yourorg.myfeature-${{ github.ref_name }}.zip
Notes:
- This workflow demonstrates building multiple runtimes and packaging into a zip during the linux-x64 job; you can centralize packaging in a separate job that depends on build artifacts.
- For production, consider signing artifacts and publishing to GitHub Releases or a private artifact store.
Step 12 — catalog entry & checksum
Create a catalog JSON entry referencing the ZIP URL and a checksum.
Example catalog entry:
{
"id": "com.yourorg.myfeature",
"name": "My Feature",
"version": "0.1.0",
"url": "https://example.com/com.yourorg.myfeature-0.1.0.zip",
"checksum": "sha256:abcdef123456..."
}
Generate a checksum:
- Unix: sha256sum com.yourorg.myfeature-0.1.0.zip | awk ‘{print $1}’
- PowerShell: (Get-FileHash .\com.yourorg.myfeature-0.1.0.zip -Algorithm SHA256).Hash
Step 13 — troubleshooting and debugging checklist
- Hook not executed:
- Confirm extension.yml lists the hook and was installed to .specify/extensions//.
- Confirm hook file path is correct and marked executable if it’s a script.
- Binary fails on host:
- Ensure correct platform binary published or require dotnet runtime and use wrapper scripts to run dotnet MyFeatureRunner.dll.
- Check file permissions: chmod +x on Unix binaries.
- Placeholder expansion:
- Verify your Spec Kit installation supports placeholders like {{spec.path}}; consult your Spec Kit version docs and update hook args.
- Inspect logs:
- Check Spec Kit or agent logs; hooks often have stdout/stderr captured — look for error messages and exit codes.
- Test locally:
- Run the wrapper or the CLI directly with the same args the hook would send to reproduce failures.
Security & governance checklist
- Do not embed secrets inside the extension. Use environment variables or configuration files that the consumer supplies.
- Document any network calls or telemetry the extension performs.
- Prefer offline, deterministic tools; minimize external dependencies.
- Provide checksums and consider signing releases for consumers to verify integrity.
- Encourage consumers to review source before installing; keep the extension small and source-available.
Best practices & gotchas
- Use JSON for structured output; it’s easier for agents and hooks to parse than free text.
- Keep the CLI deterministic and non-interactive. Hooks execute noninteractively — no prompts.
- If you can’t ship native executables for all platforms, ship a single DLL and small launcher scripts that call dotnet MyRunner.dll; document runtime requirements.
- Consider a self-test command or endpoint (e.g., /speckit.extn.myfeature.selftest) that the user can run after install to verify everything works.
Complete end-to-end checklist (before publishing)
- extension.yml exists and maps files correctly.
- addon/commands contains command files with examples and output schema.
- addon/hooks reference run wrappers and include correct placeholders.
- addon/bin contains published artifacts for supported OSes or a DLL + run wrappers.
- README documents requirements, install, usage, and troubleshooting.
- install scripts work for Unix and Windows for local testing.
- Example spec exists and tests validate analyzer logic.
- CI builds and packages artifacts and uploads ZIP to release/host.
- Catalog entry (if publishing) includes correct URL and checksum.
Note: The initial draft of this post (and all the code) was written by BlogWriter and then
edited by Jesse Liberty. Illustrations by Copilot. Caution: LLMs make mistakes; this post is offered as is.
