Creating a Spec Kit Extension

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:
  • 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)

  1. In a Spec Kit project:
    mkdir -p .specify/extensions
    cp -r com.yourorg.myfeature .specify/extensions/
  2. Confirm files:
    ls .specify/extensions/com.yourorg.myfeature
  3. Run the analyzer directly:
    dotnet run –project src/MyFeatureRunner — –spec examples/sample-spec.md Expected JSON printed or “Report written to ” if –output provided.
  4. 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.

Unknown's avatar

About Jesse Liberty

Jesse Liberty has three decades of experience writing and delivering software projects and is the author of 2 dozen books and a couple dozen online courses. Liberty is a Senior AI Engineer at the University of Pittsburgh Medical Center, and was a Team Lead and Senior Software Engineer for various corporations, a Senior Technical Evangelist for Microsoft, a Distinguished Software Engineer for AT&T, a VP for Information Services for Citibank and a Software Architect for PBS. He is a 21 year Microsoft MVP.
This entry was posted in AI, Essentials. Bookmark the permalink.