BACK TO ENGINEERING
Runtime 7 min read

Specify-CLI v1.1.0: Syncing 28 Agents, a Catalog System, and Doctor/Status Commands

The upstream Python spec-kit hit v0.7.0. Five new agents, a catalog system, recipe-style YAML for Goose, and a handful of new CLI commands. Our TypeScript port was sitting at 280 tests and 23 agents.

We synced everything. 324 tests. 28 agents. Zero dependencies. Shipped in a single commit.


I – What Changed Upstream

GitHub's spec-kit grew substantially between the version we originally ported and v0.7.0. The changes fall into four categories:

  1. Five new AI agents: Goose (YAML recipe format), Forge ({{parameters}} placeholder), Jules, Agy (skill-based), and Kiro (alias for kiro-cli)
  2. Catalog system: Dual catalogs (catalog.json + catalog.community.json) with URL-based installation, search, and caching
  3. New CLI commands: specify doctor, specify status, specify extension search, specify preset search
  4. Updated templates: All 9 command templates and 3 bash scripts refreshed

The challenge is keeping our TypeScript port at exact 1:1 parity with the Python source while maintaining zero dependencies and full test coverage.


II – YAML Recipe Generation for Goose

Goose is the first agent in spec-kit that doesn't use Markdown or TOML. It uses YAML recipes — a structured format with version, title, extensions, activities, and a prompt block:

version: 1.0.0
title: "Spec Kit Specify"
description: "Create a feature specification"
author:
  contact: spec-kit
extensions:
  - type: builtin
    name: developer
activities:
  - Spec-Driven Development
prompt: |
  You are a spec generator. Create feature specifications
  following the project's SDD workflow.

The toYamlRecipe() function in registrar.ts handles this. One subtlety: converting speckit.specify to the title "Spec Kit Specify" requires splitting the prefix correctly. The first attempt used .replace('speckit.', 'Spec Kit ') before splitting on delimiters — which produced "Spec Kit specify" because the remaining word never got capitalized.

The fix strips the prefix first, splits on [.\s-]+, capitalizes each word independently, then prepends "Spec Kit":

export function toYamlRecipe(
  commandName: string,
  description: string,
  prompt: string
): string {
  const title = commandName
    .replace(/^speckit\./, '')
    .split(/[.\s-]+/)
    .filter(w => w.length > 0)
    .map(word => word.charAt(0).toUpperCase() + word.slice(1))
    .join(' ');

  const fullTitle = commandName.startsWith('speckit.')
    ? `Spec Kit ${title}`
    : title;
  // ...
}

Small bug, easy fix. But it shows why side-by-side porting matters — the Python version doesn't have this title generation logic at all. It's a TypeScript-specific concern because our YAML recipe format is structured differently.


III – The Integration Module

The new integration.ts module manages post-init agent configurations. You can add Claude to a project that was initialized with Copilot, or remove Gemini without touching other agents.

flowchart TD
    A["specify integration add claude"] --> B["Validate agent exists"]
    B --> C["Load command templates"]
    C --> D["Register commands in .claude/commands/"]
    D --> E["Create manifest in .specify/integrations/"]
    E --> F["Integration complete"]

Each integration tracks its files in a manifest:

{
  "integration": "claude",
  "version": "1.1.0",
  "installed_at": "2026-04-15T00:00:00.000Z",
  "files": [
    "/project/.claude/commands/speckit.specify.md",
    "/project/.claude/commands/speckit.plan.md"
  ]
}

The removeIntegration function hit a Bun-specific gotcha. After removing .cursor/commands/ recursively, the parent .cursor/ directory should also be cleaned up if empty. The obvious approach — rmSync('.cursor') on the empty directory — throws EFAULT: bad address in system call argument in Bun. Node.js handles this fine. Bun requires rmdirSync for empty directory removal:

function removeEmptyParents(dir: string, stopAt: string): void {
  let current = dir;
  while (current !== stopAt && current.startsWith(stopAt)) {
    try {
      const entries = readdirSync(current);
      if (entries.length === 0) {
        rmdirSync(current); // rmSync throws EFAULT in Bun
        current = dirname(current);
      } else {
        break;
      }
    } catch {
      break;
    }
  }
}

This is the kind of runtime-specific edge case that only surfaces when you have actual tests exercising the cleanup path. Without the test, this would have been a silent bug for every Bun user.


IV – The Catalog System

The catalog module enables specify extension search and specify preset search. It fetches from upstream's community catalogs, caches results locally for 1 hour, and supports query + tag filtering with relevance sorting.

const results = searchCatalog(catalog, 'code review', ['quality']);

Search logic is straightforward — concatenate name, id, description, author, and tags into a single lowercase string, then check for substring match. Results sort by: verified first, then downloads, then stars, then alphabetical.

The module avoids external dependencies for HTTP. fetch() is available in both Bun and modern Node.js. For archive extraction, we shell out to unzip and tar — these are available on every platform and avoid pulling in zip/tar parsing libraries.

flowchart TD
    A["specify extension search 'review'"] --> B{"Cache fresh?"}
    B -->|Yes| C["Load from .specify/.cache/"]
    B -->|No| D["Fetch from GitHub"]
    D --> E["Cache catalog"]
    E --> F["Filter by query + tags"]
    C --> F
    F --> G["Sort by relevance"]
    G --> H["Display results"]

V – Doctor and Status Commands

Two diagnostic commands round out the v1.1.0 feature set.

specify doctor inspects project health:

  • Verifies .specify/ directory structure (templates, scripts, memory)
  • Checks init-options.json exists and is valid
  • Counts registered commands per agent
  • Checks extension and preset directories
  • Validates git repository exists
  • Reports issues vs warnings with actionable messages

specify status provides a project overview:

  • Project root, configured agent, script type, version
  • Installed integrations with file counts
  • Extension list with enabled/disabled status
  • Preset list with enabled/disabled status
  • Memory file count

Both commands follow the existing CLI patterns — requireProjectRoot() for validation, printError/printSuccess/printInfo for styled output, and process.exit(1) for failure cases.


VI – Test Coverage

The sync added 95 new tests across 4 test files:

File New Tests Focus
integration.test.ts 30 Manifest CRUD, add/remove integrations, new agents
catalog.test.ts 32 Search, filtering, sorting, edge cases, structure
registrar.test.ts 24 YAML recipe generation, isYamlAgent, goose registration
types.test.ts 9 28 agent configs, yaml format, new agent validation

Total: 324 tests, 990 expect() calls, 7 test files, 0 failures.

The goose registration test exercises the full pipeline — creating a command definition, registering it for the goose agent, verifying the file lands in .goose/recipes/ with a .yaml extension, then reading it back to validate the YAML recipe structure.

test('creates valid yaml recipe file', async () => {
  const command: CommandDefinition = {
    name: 'speckit.specify',
    description: 'Create a feature specification',
    content: 'You are a spec generator.',
  };

  const registered = await registerCommands(
    'goose', [command], testDir, 'core'
  );
  const filePath = registered['goose'][0];

  expect(existsSync(filePath)).toBe(true);

  const content = readFileSync(filePath, 'utf-8');
  expect(content).toContain('version: 1.0.0');
  expect(content).toContain('title: "Spec Kit Specify"');
  expect(content).toContain('prompt: |');
  expect(content).toContain('  You are a spec generator.');
});

VII – The 28 Agent Roster

With this update, @oakoliver/specify-cli supports every agent from upstream:

Agent Format Directory
claude markdown .claude/commands
copilot markdown .github/copilot/commands
cursor markdown .cursor/commands
opencode markdown .opencode/commands
gemini markdown .gemini/commands
codex toml .codex
windsurf markdown .windsurf/commands
goose yaml .goose/recipes
forge markdown .forge/commands
jules markdown .jules/commands
agy skill .agy/skills
kiro markdown .kiro/commands
+ 16 more various various

Goose is the only YAML agent. Codex is the only TOML agent. Agy and Kilocode are skill-based. Everyone else uses Markdown with frontmatter.


VIII – What I Learned

Bun's rmSync on empty directories is broken. Not a major issue, but rmSync without { recursive: true } throws EFAULT on empty directories. rmdirSync works fine. This is either a Bun bug or an intentional divergence from Node.js behavior. Either way, the fix is trivial — but only if you have tests that exercise the cleanup path.

String transformation order matters. The toYamlRecipe title bug was caused by applying replace before split. When you replace speckit. with Spec Kit and then split on [.-], the replaced portion doesn't get split again. Always split first, transform each piece independently, then join.

Catalog search doesn't need to be clever. Substring matching on a concatenated string of fields is fast enough for catalogs with hundreds of entries. BM25 would be overkill. The sort-by-verified-then-downloads heuristic handles relevance well enough that nobody will notice the search isn't fuzzy.


IX – Get It

# Install globally
npm install -g @oakoliver/specify-cli

# Or with Bun
bun install -g @oakoliver/specify-cli

New commands in v1.1.0:

# Search for extensions
specify extension search "code review"

# Search presets by tags
specify preset search --tags typescript,testing

# Diagnose project issues
specify doctor

# View project status
specify status

# Add an agent integration post-init
specify integration add claude

# Remove an integration
specify integration remove gemini

Links:

– Antonio

"Simplicity is the ultimate sophistication."