# Slash Command Generation Implementation Plan

> **For agentic workers:** REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (`- [ ]`) syntax for tracking.

**Goal:** Add `harness generate-slash-commands` CLI command that reads skill metadata and generates native slash commands for Claude Code and Gemini CLI.

**Architecture:** Two-phase generator (normalize then render) with incremental sync. Skills are discovered from `skill.yaml` files, normalized into a `SlashCommandSpec` intermediate representation, then rendered to platform-specific formats (markdown for Claude Code, TOML for Gemini CLI).

**Tech Stack:** TypeScript, Commander (CLI), Zod (validation), Vitest (testing), yaml (YAML parsing)

**Spec:** `docs/changes/slash-command-generation/proposal.md`

---

## Chunk 1: Types and Name Normalization

### Task 1: SlashCommandSpec Types

**Files:**

- Create: `packages/cli/src/slash-commands/types.ts`

- [ ] **Step 1: Write the type definitions**

```typescript
// packages/cli/src/slash-commands/types.ts

export type Platform = 'claude-code' | 'gemini-cli';

export const VALID_PLATFORMS: Platform[] = ['claude-code', 'gemini-cli'];

export interface SkillArg {
  name: string;
  description: string;
  required: boolean;
}

export interface SlashCommandSpec {
  name: string;
  namespace: string;
  fullName: string;
  description: string;
  version: string;
  cognitiveMode?: string;
  tools: string[];
  args: SkillArg[];
  skillYamlName: string;
  sourceDir: string; // directory name under skillsDir (for file lookups)

  prompt: {
    context: string;
    objective: string;
    executionContext: string;
    process: string;
  };
}

export interface GenerateOptions {
  platforms: Platform[];
  global: boolean;
  output?: string;
  skillsDir: string;
  dryRun: boolean;
  yes: boolean;
}

export const GENERATED_HEADER_CLAUDE =
  '<!-- Generated by harness generate-slash-commands. Do not edit. -->';
export const GENERATED_HEADER_GEMINI =
  '# Generated by harness generate-slash-commands. Do not edit.';
```

- [ ] **Step 2: Commit**

```bash
git add packages/cli/src/slash-commands/types.ts
git commit -m "feat: add SlashCommandSpec types for slash command generation"
```

### Task 2: Name Normalization

**Files:**

- Create: `packages/cli/src/slash-commands/normalize-name.ts`
- Test: `packages/cli/src/slash-commands/__tests__/normalize-name.test.ts`

- [ ] **Step 1: Write the failing tests**

```typescript
// packages/cli/src/slash-commands/__tests__/normalize-name.test.ts
import { describe, it, expect } from 'vitest';
import { normalizeName } from '../normalize-name';

describe('normalizeName', () => {
  it('strips leading harness- prefix', () => {
    expect(normalizeName('harness-execution')).toBe('execution');
  });

  it('strips leading harness- prefix from multi-word names', () => {
    expect(normalizeName('harness-code-review')).toBe('code-review');
  });

  it('strips interior -harness- from names', () => {
    expect(normalizeName('initialize-harness-project')).toBe('initialize-project');
  });

  it('strips interior -harness- from other names', () => {
    expect(normalizeName('add-harness-component')).toBe('add-component');
  });

  it('passes through names without harness', () => {
    expect(normalizeName('cleanup-dead-code')).toBe('cleanup-dead-code');
  });

  it('passes through single-word names', () => {
    expect(normalizeName('validate')).toBe('validate');
  });

  it('does not strip harness from end position', () => {
    expect(normalizeName('check-harness')).toBe('check');
  });

  it('handles name that is just "harness"', () => {
    expect(normalizeName('harness')).toBe('harness');
  });
});
```

- [ ] **Step 2: Run tests to verify they fail**

Run: `cd packages/cli && npx vitest run src/slash-commands/__tests__/normalize-name.test.ts`
Expected: FAIL — module not found

- [ ] **Step 3: Write the implementation**

```typescript
// packages/cli/src/slash-commands/normalize-name.ts

/**
 * Normalize a skill name for use as a slash command name.
 *
 * Rules:
 * 1. Strip leading "harness-" prefix
 * 2. Strip interior "-harness-" segments
 * 3. Strip trailing "-harness"
 * 4. Pass through all other names unchanged
 */
export function normalizeName(skillName: string): string {
  let name = skillName;

  // Rule 1: strip leading "harness-"
  if (name.startsWith('harness-')) {
    name = name.slice('harness-'.length);
  }

  // Rule 2: strip interior "-harness-"
  name = name.replace(/-harness-/g, '-');

  // Rule 3: strip trailing "-harness"
  if (name.endsWith('-harness')) {
    name = name.slice(0, -'-harness'.length);
  }

  return name;
}
```

- [ ] **Step 4: Run tests to verify they pass**

Run: `cd packages/cli && npx vitest run src/slash-commands/__tests__/normalize-name.test.ts`
Expected: All 8 tests PASS

- [ ] **Step 5: Commit**

```bash
git add packages/cli/src/slash-commands/normalize-name.ts packages/cli/src/slash-commands/__tests__/normalize-name.test.ts
git commit -m "feat: add name normalization for slash command generation"
```

### Task 3: Argument Hint Builder

**Files:**

- Create: `packages/cli/src/slash-commands/argument-hint.ts`
- Test: `packages/cli/src/slash-commands/__tests__/argument-hint.test.ts`

- [ ] **Step 1: Write the failing tests**

```typescript
// packages/cli/src/slash-commands/__tests__/argument-hint.test.ts
import { describe, it, expect } from 'vitest';
import { buildArgumentHint } from '../argument-hint';

describe('buildArgumentHint', () => {
  it('returns empty string for no args', () => {
    expect(buildArgumentHint([])).toBe('');
  });

  it('formats a required arg', () => {
    expect(
      buildArgumentHint([{ name: 'target', description: 'Target path', required: true }])
    ).toBe('--target <target>');
  });

  it('formats an optional arg', () => {
    expect(
      buildArgumentHint([{ name: 'path', description: 'Project path', required: false }])
    ).toBe('[--path <path>]');
  });

  it('formats mixed required and optional args', () => {
    expect(
      buildArgumentHint([
        { name: 'target', description: 'Target', required: true },
        { name: 'path', description: 'Path', required: false },
      ])
    ).toBe('--target <target> [--path <path>]');
  });
});
```

- [ ] **Step 2: Run tests to verify they fail**

Run: `cd packages/cli && npx vitest run src/slash-commands/__tests__/argument-hint.test.ts`
Expected: FAIL — module not found

- [ ] **Step 3: Write the implementation**

```typescript
// packages/cli/src/slash-commands/argument-hint.ts
import type { SkillArg } from './types';

export function buildArgumentHint(args: SkillArg[]): string {
  return args
    .map((arg) => (arg.required ? `--${arg.name} <${arg.name}>` : `[--${arg.name} <${arg.name}>]`))
    .join(' ');
}
```

- [ ] **Step 4: Run tests to verify they pass**

Run: `cd packages/cli && npx vitest run src/slash-commands/__tests__/argument-hint.test.ts`
Expected: All 4 tests PASS

- [ ] **Step 5: Commit**

```bash
git add packages/cli/src/slash-commands/argument-hint.ts packages/cli/src/slash-commands/__tests__/argument-hint.test.ts
git commit -m "feat: add argument hint builder for slash command generation"
```

---

## Chunk 2: Normalizer — Skill Discovery and Transformation

### Task 4: Normalizer

**Files:**

- Create: `packages/cli/src/slash-commands/normalize.ts`
- Test: `packages/cli/src/slash-commands/__tests__/normalize.test.ts`
- Reference: `packages/cli/src/skill/schema.ts` (SkillMetadataSchema)
- Reference: `packages/cli/src/commands/skill/list.ts` (discovery pattern)

- [ ] **Step 1: Create test fixtures**

Create a minimal fixture directory for tests:

```bash
mkdir -p packages/cli/src/slash-commands/__tests__/fixtures/valid-skill
mkdir -p packages/cli/src/slash-commands/__tests__/fixtures/no-platforms
mkdir -p packages/cli/src/slash-commands/__tests__/fixtures/missing-skill-md
```

```yaml
# packages/cli/src/slash-commands/__tests__/fixtures/valid-skill/skill.yaml
name: harness-test-skill
version: '1.0.0'
description: A test skill for unit tests
cognitive_mode: meticulous-implementer
type: rigid
triggers:
  - manual
platforms:
  - claude-code
  - gemini-cli
tools:
  - Read
  - Write
  - Bash
cli:
  command: harness skill run harness-test-skill
  args:
    - name: path
      description: Project root path
      required: false
mcp:
  tool: run_skill
  input:
    skill: harness-test-skill
    path: string
phases:
  - name: execute
    description: Run the test workflow
    required: true
state:
  persistent: true
  files:
    - .harness/state.json
```

```markdown
# packages/cli/src/slash-commands/**tests**/fixtures/valid-skill/SKILL.md

# Test Skill

This is a test skill for unit testing the normalizer.

## Steps

1. Do thing A
2. Do thing B
```

```yaml
# packages/cli/src/slash-commands/__tests__/fixtures/no-platforms/skill.yaml
name: no-platform-skill
version: '1.0.0'
description: A skill with no matching platforms
type: rigid
triggers:
  - manual
platforms:
  - gemini-cli
tools:
  - Read
```

Note: This fixture has `platforms: [gemini-cli]` so when we filter for `claude-code` it gets excluded, properly testing platform filtering (not schema rejection).

```yaml
# packages/cli/src/slash-commands/__tests__/fixtures/missing-skill-md/skill.yaml
name: harness-no-md
version: '1.0.0'
description: A skill without SKILL.md
type: rigid
triggers:
  - manual
platforms:
  - claude-code
tools:
  - Read
```

- [ ] **Step 2: Write the failing tests**

```typescript
// packages/cli/src/slash-commands/__tests__/normalize.test.ts
import { describe, it, expect } from 'vitest';
import path from 'node:path';
import { normalizeSkills } from '../normalize';

const fixturesDir = path.join(__dirname, 'fixtures');

describe('normalizeSkills', () => {
  it('normalizes a valid skill into a SlashCommandSpec', () => {
    const specs = normalizeSkills(fixturesDir, ['claude-code']);
    const spec = specs.find((s) => s.skillYamlName === 'harness-test-skill');

    expect(spec).toBeDefined();
    expect(spec!.name).toBe('test-skill');
    expect(spec!.namespace).toBe('harness');
    expect(spec!.fullName).toBe('harness:test-skill');
    expect(spec!.description).toBe('A test skill for unit tests');
    expect(spec!.cognitiveMode).toBe('meticulous-implementer');
    expect(spec!.tools).toContain('Read');
    expect(spec!.args).toHaveLength(1);
    expect(spec!.args[0].name).toBe('path');
    expect(spec!.sourceDir).toBe('valid-skill');
  });

  it('includes Read in tools even if not in skill.yaml', () => {
    const specs = normalizeSkills(fixturesDir, ['claude-code']);
    const spec = specs.find((s) => s.skillYamlName === 'harness-test-skill');
    expect(spec!.tools).toContain('Read');
  });

  it('filters out skills that do not match target platform', () => {
    const specs = normalizeSkills(fixturesDir, ['claude-code']);
    const noPlat = specs.find((s) => s.skillYamlName === 'no-platform-skill');
    expect(noPlat).toBeUndefined();
  });

  it('generates prompt.context with cognitive mode and type', () => {
    const specs = normalizeSkills(fixturesDir, ['claude-code']);
    const spec = specs.find((s) => s.skillYamlName === 'harness-test-skill');
    expect(spec!.prompt.context).toContain('meticulous-implementer');
    expect(spec!.prompt.context).toContain('rigid');
  });

  it('generates prompt.objective from description and phases', () => {
    const specs = normalizeSkills(fixturesDir, ['claude-code']);
    const spec = specs.find((s) => s.skillYamlName === 'harness-test-skill');
    expect(spec!.prompt.objective).toContain('A test skill for unit tests');
    expect(spec!.prompt.objective).toContain('execute');
  });

  it('generates prompt.process with MCP-first logic', () => {
    const specs = normalizeSkills(fixturesDir, ['claude-code']);
    const spec = specs.find((s) => s.skillYamlName === 'harness-test-skill');
    expect(spec!.prompt.process).toContain('mcp__harness__run_skill');
    expect(spec!.prompt.process).toContain('harness-test-skill');
  });

  it('handles skills with missing SKILL.md gracefully', () => {
    const specs = normalizeSkills(fixturesDir, ['claude-code']);
    const spec = specs.find((s) => s.skillYamlName === 'harness-no-md');
    expect(spec).toBeDefined();
    expect(spec!.prompt.executionContext).toBe('');
  });

  it('defaults args to empty array when cli is absent', () => {
    const specs = normalizeSkills(fixturesDir, ['claude-code']);
    const spec = specs.find((s) => s.skillYamlName === 'harness-no-md');
    expect(spec!.args).toEqual([]);
  });

  it('detects name collisions and throws', () => {
    // This would require two fixtures that normalize to the same name.
    // Covered by normalize-name unit tests; here we just verify the check runs.
    // No collision in our fixtures, so this should not throw.
    expect(() => normalizeSkills(fixturesDir, ['claude-code'])).not.toThrow();
  });
});
```

- [ ] **Step 3: Run tests to verify they fail**

Run: `cd packages/cli && npx vitest run src/slash-commands/__tests__/normalize.test.ts`
Expected: FAIL — module not found

- [ ] **Step 4: Write the implementation**

```typescript
// packages/cli/src/slash-commands/normalize.ts
import fs from 'node:fs';
import path from 'node:path';
import { parse } from 'yaml';
import { SkillMetadataSchema } from '../skill/schema';
import type { Platform, SlashCommandSpec, SkillArg } from './types';
import { normalizeName } from './normalize-name';

export function normalizeSkills(skillsDir: string, platforms: Platform[]): SlashCommandSpec[] {
  const entries = fs.readdirSync(skillsDir, { withFileTypes: true }).filter((d) => d.isDirectory());

  const specs: SlashCommandSpec[] = [];
  const nameMap = new Map<string, string>(); // normalized name -> original skill name

  for (const entry of entries) {
    const yamlPath = path.join(skillsDir, entry.name, 'skill.yaml');
    if (!fs.existsSync(yamlPath)) continue;

    let raw: string;
    try {
      raw = fs.readFileSync(yamlPath, 'utf-8');
    } catch {
      continue;
    }

    const parsed = parse(raw);
    const result = SkillMetadataSchema.safeParse(parsed);
    if (!result.success) {
      console.warn(`Skipping ${entry.name}: invalid skill.yaml`);
      continue;
    }

    const meta = result.data;

    // Filter by platform
    const matchesPlatform = platforms.some((p) => meta.platforms?.includes(p));
    if (!matchesPlatform) continue;

    const normalized = normalizeName(meta.name);

    // Collision detection
    if (nameMap.has(normalized)) {
      throw new Error(
        `Name collision: skills "${nameMap.get(normalized)}" and "${meta.name}" both normalize to "${normalized}"`
      );
    }
    nameMap.set(normalized, meta.name);

    // Read SKILL.md if it exists
    const skillMdPath = path.join(skillsDir, entry.name, 'SKILL.md');
    const skillMdContent = fs.existsSync(skillMdPath) ? fs.readFileSync(skillMdPath, 'utf-8') : '';
    // Compute relative path from skillsDir for @ references
    const skillMdRelative = path.relative(
      process.cwd(),
      path.join(skillsDir, entry.name, 'SKILL.md')
    );
    const skillYamlRelative = path.relative(
      process.cwd(),
      path.join(skillsDir, entry.name, 'skill.yaml')
    );

    // Build args
    const args: SkillArg[] = (meta.cli?.args ?? []).map((a) => ({
      name: a.name,
      description: a.description ?? '',
      required: a.required ?? false,
    }));

    // Ensure Read is in tools
    const tools = [...(meta.tools ?? [])];
    if (!tools.includes('Read')) {
      tools.push('Read');
    }

    // Build prompt sections
    const contextLines: string[] = [];
    if (meta.cognitive_mode) {
      contextLines.push(`Cognitive mode: ${meta.cognitive_mode}`);
    }
    if (meta.type) {
      contextLines.push(`Type: ${meta.type}`);
    }
    if (meta.state?.persistent) {
      const files = meta.state.files?.join(', ') ?? '';
      contextLines.push(`State: persistent${files ? ` (files: ${files})` : ''}`);
    }

    const objectiveLines = [meta.description];
    if (meta.phases && meta.phases.length > 0) {
      objectiveLines.push('');
      objectiveLines.push('Phases:');
      for (const phase of meta.phases) {
        const req = phase.required !== false ? '' : ' (optional)';
        objectiveLines.push(`- ${phase.name}: ${phase.description}${req}`);
      }
    }

    const executionContextLines: string[] = [];
    if (skillMdContent) {
      executionContextLines.push(`@${skillMdRelative}`);
      executionContextLines.push(`@${skillYamlRelative}`);
    }

    const processLines: string[] = [];
    if (meta.mcp?.tool) {
      processLines.push(`1. Try: invoke mcp__harness__${meta.mcp.tool} with skill: "${meta.name}"`);
      processLines.push(`2. If MCP unavailable: read SKILL.md and follow its workflow directly`);
      processLines.push(`3. Pass through any arguments provided by the user`);
    } else {
      processLines.push(`1. Read SKILL.md and follow its workflow directly`);
      processLines.push(`2. Pass through any arguments provided by the user`);
    }

    specs.push({
      name: normalized,
      namespace: 'harness',
      fullName: `harness:${normalized}`,
      description: meta.description,
      version: meta.version,
      cognitiveMode: meta.cognitive_mode,
      tools,
      args,
      skillYamlName: meta.name,
      sourceDir: entry.name,
      prompt: {
        context: contextLines.join('\n'),
        objective: objectiveLines.join('\n'),
        executionContext: executionContextLines.join('\n'),
        process: processLines.join('\n'),
      },
    });
  }

  return specs;
}
```

- [ ] **Step 5: Run tests to verify they pass**

Run: `cd packages/cli && npx vitest run src/slash-commands/__tests__/normalize.test.ts`
Expected: All 9 tests PASS

- [ ] **Step 6: Commit**

```bash
git add packages/cli/src/slash-commands/normalize.ts packages/cli/src/slash-commands/__tests__/normalize.test.ts packages/cli/src/slash-commands/__tests__/fixtures/
git commit -m "feat: add skill normalizer for slash command generation"
```

---

## Chunk 3: Platform Renderers

### Task 5: Claude Code Renderer

**Files:**

- Create: `packages/cli/src/slash-commands/render-claude-code.ts`
- Test: `packages/cli/src/slash-commands/__tests__/render-claude-code.test.ts`

- [ ] **Step 1: Write the failing tests**

```typescript
// packages/cli/src/slash-commands/__tests__/render-claude-code.test.ts
import { describe, it, expect } from 'vitest';
import { renderClaudeCode } from '../render-claude-code';
import { GENERATED_HEADER_CLAUDE } from '../types';
import type { SlashCommandSpec } from '../types';

const baseSpec: SlashCommandSpec = {
  name: 'execution',
  namespace: 'harness',
  fullName: 'harness:execution',
  description: 'Execute implementation plans',
  version: '1.0.0',
  cognitiveMode: 'meticulous-implementer',
  tools: ['Read', 'Write', 'Bash'],
  args: [{ name: 'path', description: 'Project path', required: false }],
  skillYamlName: 'harness-execution',
  sourceDir: 'harness-execution',
  prompt: {
    context: 'Cognitive mode: meticulous-implementer\nType: rigid',
    objective: 'Execute implementation plans',
    executionContext:
      '@agents/skills/claude-code/harness-execution/SKILL.md\n@agents/skills/claude-code/harness-execution/skill.yaml',
    process:
      '1. Try: invoke mcp__harness__run_skill with skill: "harness-execution"\n2. If MCP unavailable: read SKILL.md and follow its workflow directly\n3. Pass through any arguments provided by the user',
  },
};

describe('renderClaudeCode', () => {
  it('starts with the generated header', () => {
    const output = renderClaudeCode(baseSpec);
    expect(output.startsWith(GENERATED_HEADER_CLAUDE)).toBe(true);
  });

  it('includes YAML frontmatter with name and description', () => {
    const output = renderClaudeCode(baseSpec);
    expect(output).toContain('name: harness:execution');
    expect(output).toContain('description: Execute implementation plans');
  });

  it('includes argument-hint when args exist', () => {
    const output = renderClaudeCode(baseSpec);
    expect(output).toContain('argument-hint: "[--path <path>]"');
  });

  it('omits argument-hint when args are empty', () => {
    const noArgs = { ...baseSpec, args: [] };
    const output = renderClaudeCode(noArgs);
    expect(output).not.toContain('argument-hint');
  });

  it('includes allowed-tools list', () => {
    const output = renderClaudeCode(baseSpec);
    expect(output).toContain('  - Read');
    expect(output).toContain('  - Write');
    expect(output).toContain('  - Bash');
  });

  it('includes all four prompt sections', () => {
    const output = renderClaudeCode(baseSpec);
    expect(output).toContain('<context>');
    expect(output).toContain('</context>');
    expect(output).toContain('<objective>');
    expect(output).toContain('</objective>');
    expect(output).toContain('<execution_context>');
    expect(output).toContain('</execution_context>');
    expect(output).toContain('<process>');
    expect(output).toContain('</process>');
  });

  it('omits execution_context section when empty', () => {
    const noCtx = { ...baseSpec, prompt: { ...baseSpec.prompt, executionContext: '' } };
    const output = renderClaudeCode(noCtx);
    expect(output).not.toContain('<execution_context>');
  });
});
```

- [ ] **Step 2: Run tests to verify they fail**

Run: `cd packages/cli && npx vitest run src/slash-commands/__tests__/render-claude-code.test.ts`
Expected: FAIL — module not found

- [ ] **Step 3: Write the implementation**

```typescript
// packages/cli/src/slash-commands/render-claude-code.ts
import type { SlashCommandSpec } from './types';
import { GENERATED_HEADER_CLAUDE } from './types';
import { buildArgumentHint } from './argument-hint';

export function renderClaudeCode(spec: SlashCommandSpec): string {
  const lines: string[] = [GENERATED_HEADER_CLAUDE];

  // YAML frontmatter
  lines.push('---');
  lines.push(`name: ${spec.fullName}`);
  lines.push(`description: ${spec.description}`);

  const hint = buildArgumentHint(spec.args);
  if (hint) {
    lines.push(`argument-hint: "${hint}"`);
  }

  if (spec.tools.length > 0) {
    lines.push('allowed-tools:');
    for (const tool of spec.tools) {
      lines.push(`  - ${tool}`);
    }
  }

  lines.push('---');
  lines.push('');

  // Context section
  lines.push('<context>');
  lines.push(spec.prompt.context);
  lines.push('</context>');
  lines.push('');

  // Objective section
  lines.push('<objective>');
  lines.push(spec.prompt.objective);
  lines.push('</objective>');
  lines.push('');

  // Execution context section (only if non-empty)
  if (spec.prompt.executionContext) {
    lines.push('<execution_context>');
    lines.push(spec.prompt.executionContext);
    lines.push('</execution_context>');
    lines.push('');
  }

  // Process section
  lines.push('<process>');
  lines.push(spec.prompt.process);
  lines.push('</process>');
  lines.push('');

  return lines.join('\n');
}
```

- [ ] **Step 4: Run tests to verify they pass**

Run: `cd packages/cli && npx vitest run src/slash-commands/__tests__/render-claude-code.test.ts`
Expected: All 7 tests PASS

- [ ] **Step 5: Commit**

```bash
git add packages/cli/src/slash-commands/render-claude-code.ts packages/cli/src/slash-commands/__tests__/render-claude-code.test.ts
git commit -m "feat: add Claude Code renderer for slash command generation"
```

### Task 6: Gemini CLI Renderer

**Files:**

- Create: `packages/cli/src/slash-commands/render-gemini.ts`
- Test: `packages/cli/src/slash-commands/__tests__/render-gemini.test.ts`

- [ ] **Step 1: Write the failing tests**

```typescript
// packages/cli/src/slash-commands/__tests__/render-gemini.test.ts
import { describe, it, expect } from 'vitest';
import { renderGemini } from '../render-gemini';
import { GENERATED_HEADER_GEMINI } from '../types';
import type { SlashCommandSpec } from '../types';

const baseSpec: SlashCommandSpec = {
  name: 'execution',
  namespace: 'harness',
  fullName: 'harness:execution',
  description: 'Execute implementation plans',
  version: '1.0.0',
  cognitiveMode: 'meticulous-implementer',
  tools: ['Read', 'Write', 'Bash'],
  args: [{ name: 'path', description: 'Project path', required: false }],
  skillYamlName: 'harness-execution',
  sourceDir: 'harness-execution',
  prompt: {
    context: 'Cognitive mode: meticulous-implementer\nType: rigid',
    objective: 'Execute implementation plans',
    executionContext:
      '@agents/skills/claude-code/harness-execution/SKILL.md\n@agents/skills/claude-code/harness-execution/skill.yaml',
    process:
      '1. Try: invoke mcp__harness__run_skill with skill: "harness-execution"\n2. If MCP unavailable: read SKILL.md and follow its workflow directly\n3. Pass through any arguments provided by the user',
  },
};

const skillMdContent = '# Test Skill\n\nDo things.';
const skillYamlContent = 'name: harness-execution\nversion: "1.0.0"';

describe('renderGemini', () => {
  it('starts with the generated header', () => {
    const output = renderGemini(baseSpec, skillMdContent, skillYamlContent);
    expect(output.startsWith(GENERATED_HEADER_GEMINI)).toBe(true);
  });

  it('includes description field', () => {
    const output = renderGemini(baseSpec, skillMdContent, skillYamlContent);
    expect(output).toContain('description = "Execute implementation plans"');
  });

  it('inlines SKILL.md content in execution_context', () => {
    const output = renderGemini(baseSpec, skillMdContent, skillYamlContent);
    expect(output).toContain('--- SKILL.md');
    expect(output).toContain('# Test Skill');
    expect(output).toContain('Do things.');
  });

  it('inlines skill.yaml content in execution_context', () => {
    const output = renderGemini(baseSpec, skillMdContent, skillYamlContent);
    expect(output).toContain('--- skill.yaml');
    expect(output).toContain('name: harness-execution');
  });

  it('includes all four prompt sections', () => {
    const output = renderGemini(baseSpec, skillMdContent, skillYamlContent);
    expect(output).toContain('<context>');
    expect(output).toContain('<objective>');
    expect(output).toContain('<execution_context>');
    expect(output).toContain('<process>');
  });

  it('omits execution_context when no content provided', () => {
    const noCtx = { ...baseSpec, prompt: { ...baseSpec.prompt, executionContext: '' } };
    const output = renderGemini(noCtx, '', '');
    expect(output).not.toContain('<execution_context>');
  });

  it('uses SKILL.md fallback wording in process when MCP unavailable', () => {
    const output = renderGemini(baseSpec, skillMdContent, skillYamlContent);
    expect(output).toContain('follow the SKILL.md workflow provided above directly');
  });

  it('escapes double quotes in description', () => {
    const quotedSpec = { ...baseSpec, description: 'Run the "fast" mode' };
    const output = renderGemini(quotedSpec, skillMdContent, skillYamlContent);
    expect(output).toContain('description = "Run the \\"fast\\" mode"');
  });
});
```

- [ ] **Step 2: Run tests to verify they fail**

Run: `cd packages/cli && npx vitest run src/slash-commands/__tests__/render-gemini.test.ts`
Expected: FAIL — module not found

- [ ] **Step 3: Write the implementation**

```typescript
// packages/cli/src/slash-commands/render-gemini.ts
import type { SlashCommandSpec } from './types';
import { GENERATED_HEADER_GEMINI } from './types';

/**
 * Escape triple quotes for TOML multi-line strings.
 * TOML multi-line basic strings end at the first unescaped """.
 * We break up any """ sequences by inserting a backslash-escaped quote.
 */
function escapeToml(content: string): string {
  return content.replace(/"""/g, '""\\\"');
}

export function renderGemini(
  spec: SlashCommandSpec,
  skillMdContent: string,
  skillYamlContent: string
): string {
  const lines: string[] = [GENERATED_HEADER_GEMINI];

  const safeDesc = spec.description.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
  lines.push(`description = "${safeDesc}"`);
  lines.push('prompt = """');

  // Context section
  lines.push('<context>');
  lines.push(spec.prompt.context);
  lines.push('</context>');
  lines.push('');

  // Objective section
  lines.push('<objective>');
  lines.push(spec.prompt.objective);
  lines.push('</objective>');
  lines.push('');

  // Execution context section — inline file contents for Gemini
  if (skillMdContent || skillYamlContent) {
    lines.push('<execution_context>');
    if (skillMdContent) {
      // Extract relative path from the @ reference
      const mdPath = spec.prompt.executionContext.split('\n')[0]?.replace(/^@/, '') ?? '';
      lines.push(`--- SKILL.md (${mdPath}) ---`);
      lines.push(escapeToml(skillMdContent));
      lines.push('');
    }
    if (skillYamlContent) {
      const refs = spec.prompt.executionContext.split('\n');
      const yamlPath = (refs[1] ?? refs[0] ?? '').replace(/^@/, '');
      lines.push(`--- skill.yaml (${yamlPath}) ---`);
      lines.push(escapeToml(skillYamlContent));
    }
    lines.push('</execution_context>');
    lines.push('');
  }

  // Process section — replace @file reference wording with inline wording
  const geminiProcess = spec.prompt.process.replace(
    'read SKILL.md and follow its workflow directly',
    'follow the SKILL.md workflow provided above directly'
  );
  lines.push('<process>');
  lines.push(geminiProcess);
  lines.push('</process>');

  lines.push('"""');
  lines.push('');

  return lines.join('\n');
}
```

- [ ] **Step 4: Run tests to verify they pass**

Run: `cd packages/cli && npx vitest run src/slash-commands/__tests__/render-gemini.test.ts`
Expected: All 7 tests PASS

- [ ] **Step 5: Commit**

```bash
git add packages/cli/src/slash-commands/render-gemini.ts packages/cli/src/slash-commands/__tests__/render-gemini.test.ts
git commit -m "feat: add Gemini CLI renderer for slash command generation"
```

---

## Chunk 4: Sync Logic

### Task 7: Sync Engine

**Files:**

- Create: `packages/cli/src/slash-commands/sync.ts`
- Test: `packages/cli/src/slash-commands/__tests__/sync.test.ts`

- [ ] **Step 1: Write the failing tests**

```typescript
// packages/cli/src/slash-commands/__tests__/sync.test.ts
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import fs from 'node:fs';
import path from 'node:path';
import os from 'node:os';
import { computeSyncPlan, applySyncPlan } from '../sync';
import { GENERATED_HEADER_CLAUDE } from '../types';
import type { SlashCommandSpec } from '../types';

function makeTmpDir(): string {
  return fs.mkdtempSync(path.join(os.tmpdir(), 'sync-test-'));
}

const spec1: SlashCommandSpec = {
  name: 'execution',
  namespace: 'harness',
  fullName: 'harness:execution',
  description: 'Execute plans',
  version: '1.0.0',
  tools: ['Read'],
  args: [],
  skillYamlName: 'harness-execution',
  sourceDir: 'harness-execution',
  prompt: { context: '', objective: '', executionContext: '', process: '' },
};

describe('computeSyncPlan', () => {
  let tmpDir: string;

  beforeEach(() => {
    tmpDir = makeTmpDir();
  });
  afterEach(() => {
    fs.rmSync(tmpDir, { recursive: true, force: true });
  });

  it('detects new files when output dir is empty', () => {
    const rendered = new Map([['execution.md', 'content']]);
    const plan = computeSyncPlan(tmpDir, rendered);
    expect(plan.added).toEqual(['execution.md']);
    expect(plan.updated).toEqual([]);
    expect(plan.removed).toEqual([]);
  });

  it('detects unchanged files', () => {
    fs.writeFileSync(path.join(tmpDir, 'execution.md'), 'content');
    const rendered = new Map([['execution.md', 'content']]);
    const plan = computeSyncPlan(tmpDir, rendered);
    expect(plan.unchanged).toEqual(['execution.md']);
    expect(plan.added).toEqual([]);
    expect(plan.updated).toEqual([]);
  });

  it('detects updated files', () => {
    fs.writeFileSync(path.join(tmpDir, 'execution.md'), 'old content');
    const rendered = new Map([['execution.md', 'new content']]);
    const plan = computeSyncPlan(tmpDir, rendered);
    expect(plan.updated).toEqual(['execution.md']);
  });

  it('detects removed files (orphans with generated header)', () => {
    fs.writeFileSync(
      path.join(tmpDir, 'old-skill.md'),
      `${GENERATED_HEADER_CLAUDE}\n---\nname: harness:old\n---`
    );
    const rendered = new Map<string, string>();
    const plan = computeSyncPlan(tmpDir, rendered);
    expect(plan.removed).toEqual(['old-skill.md']);
  });

  it('does not flag non-generated files as removed', () => {
    fs.writeFileSync(path.join(tmpDir, 'custom.md'), 'hand-authored content');
    const rendered = new Map<string, string>();
    const plan = computeSyncPlan(tmpDir, rendered);
    expect(plan.removed).toEqual([]);
  });
});

describe('applySyncPlan', () => {
  let tmpDir: string;

  beforeEach(() => {
    tmpDir = makeTmpDir();
  });
  afterEach(() => {
    fs.rmSync(tmpDir, { recursive: true, force: true });
  });

  it('writes new files', () => {
    const rendered = new Map([['execution.md', 'new content']]);
    applySyncPlan(
      tmpDir,
      rendered,
      { added: ['execution.md'], updated: [], removed: [], unchanged: [] },
      false
    );
    expect(fs.readFileSync(path.join(tmpDir, 'execution.md'), 'utf-8')).toBe('new content');
  });

  it('overwrites updated files', () => {
    fs.writeFileSync(path.join(tmpDir, 'execution.md'), 'old');
    const rendered = new Map([['execution.md', 'new']]);
    applySyncPlan(
      tmpDir,
      rendered,
      { added: [], updated: ['execution.md'], removed: [], unchanged: [] },
      false
    );
    expect(fs.readFileSync(path.join(tmpDir, 'execution.md'), 'utf-8')).toBe('new');
  });

  it('deletes removed files when deleteOrphans is true', () => {
    fs.writeFileSync(path.join(tmpDir, 'old.md'), 'x');
    applySyncPlan(
      tmpDir,
      new Map(),
      { added: [], updated: [], removed: ['old.md'], unchanged: [] },
      true
    );
    expect(fs.existsSync(path.join(tmpDir, 'old.md'))).toBe(false);
  });

  it('does not delete removed files when deleteOrphans is false', () => {
    fs.writeFileSync(path.join(tmpDir, 'old.md'), 'x');
    applySyncPlan(
      tmpDir,
      new Map(),
      { added: [], updated: [], removed: ['old.md'], unchanged: [] },
      false
    );
    expect(fs.existsSync(path.join(tmpDir, 'old.md'))).toBe(true);
  });
});
```

- [ ] **Step 2: Run tests to verify they fail**

Run: `cd packages/cli && npx vitest run src/slash-commands/__tests__/sync.test.ts`
Expected: FAIL — module not found

- [ ] **Step 3: Write the implementation**

```typescript
// packages/cli/src/slash-commands/sync.ts
import fs from 'node:fs';
import path from 'node:path';
import { GENERATED_HEADER_CLAUDE, GENERATED_HEADER_GEMINI } from './types';

export interface SyncResult {
  added: string[];
  updated: string[];
  removed: string[];
  unchanged: string[];
}

/**
 * Compare rendered files against existing files in the output directory.
 * Only flags files with a generated header as removable orphans.
 */
export function computeSyncPlan(outputDir: string, rendered: Map<string, string>): SyncResult {
  const added: string[] = [];
  const updated: string[] = [];
  const removed: string[] = [];
  const unchanged: string[] = [];

  // Check rendered files against existing
  for (const [filename, content] of rendered) {
    const filePath = path.join(outputDir, filename);
    if (!fs.existsSync(filePath)) {
      added.push(filename);
    } else {
      const existing = fs.readFileSync(filePath, 'utf-8');
      if (existing === content) {
        unchanged.push(filename);
      } else {
        updated.push(filename);
      }
    }
  }

  // Check for orphaned generated files
  if (fs.existsSync(outputDir)) {
    const existing = fs.readdirSync(outputDir).filter((f) => {
      const stat = fs.statSync(path.join(outputDir, f));
      return stat.isFile();
    });

    for (const filename of existing) {
      if (rendered.has(filename)) continue;

      // Only flag as removed if it has a generated header
      const content = fs.readFileSync(path.join(outputDir, filename), 'utf-8');
      if (
        content.startsWith(GENERATED_HEADER_CLAUDE) ||
        content.startsWith(GENERATED_HEADER_GEMINI)
      ) {
        removed.push(filename);
      }
    }
  }

  return { added, updated, removed, unchanged };
}

/**
 * Apply the sync plan: write new/updated files, optionally delete orphans.
 */
export function applySyncPlan(
  outputDir: string,
  rendered: Map<string, string>,
  plan: SyncResult,
  deleteOrphans: boolean
): void {
  // Ensure output directory exists
  fs.mkdirSync(outputDir, { recursive: true });

  // Write new and updated files
  for (const filename of [...plan.added, ...plan.updated]) {
    const content = rendered.get(filename);
    if (content !== undefined) {
      fs.writeFileSync(path.join(outputDir, filename), content);
    }
  }

  // Delete orphans if approved
  if (deleteOrphans) {
    for (const filename of plan.removed) {
      const filePath = path.join(outputDir, filename);
      if (fs.existsSync(filePath)) {
        fs.unlinkSync(filePath);
      }
    }
  }
}
```

- [ ] **Step 4: Run tests to verify they pass**

Run: `cd packages/cli && npx vitest run src/slash-commands/__tests__/sync.test.ts`
Expected: All 9 tests PASS

- [ ] **Step 5: Commit**

```bash
git add packages/cli/src/slash-commands/sync.ts packages/cli/src/slash-commands/__tests__/sync.test.ts
git commit -m "feat: add sync engine for slash command generation"
```

---

## Chunk 5: CLI Command and Integration

### Task 8: CLI Command

**Files:**

- Create: `packages/cli/src/commands/generate-slash-commands.ts`
- Modify: `packages/cli/src/index.ts` (add command registration)

- [ ] **Step 1: Write the CLI command**

```typescript
// packages/cli/src/commands/generate-slash-commands.ts
import { Command } from 'commander';
import fs from 'node:fs';
import path from 'node:path';
import os from 'node:os';
import readline from 'node:readline';
import { normalizeSkills } from '../slash-commands/normalize';
import { renderClaudeCode } from '../slash-commands/render-claude-code';
import { renderGemini } from '../slash-commands/render-gemini';
import { computeSyncPlan, applySyncPlan } from '../slash-commands/sync';
import { resolveSkillsDir } from '../utils/paths';
import { CLIError, ExitCode } from '../utils/errors';
import type { Platform, GenerateOptions } from '../slash-commands/types';
import { VALID_PLATFORMS } from '../slash-commands/types';

export interface GenerateResult {
  platform: string;
  added: string[];
  updated: string[];
  removed: string[];
  unchanged: string[];
  outputDir: string;
}

function resolveOutputDir(platform: Platform, opts: { global: boolean; output?: string }): string {
  if (opts.output) {
    return path.join(opts.output, 'harness');
  }
  if (opts.global) {
    const home = os.homedir();
    return platform === 'claude-code'
      ? path.join(home, '.claude', 'commands', 'harness')
      : path.join(home, '.gemini', 'commands', 'harness');
  }
  return platform === 'claude-code'
    ? path.join('agents', 'commands', 'claude-code', 'harness')
    : path.join('agents', 'commands', 'gemini-cli', 'harness');
}

function fileExtension(platform: Platform): string {
  return platform === 'claude-code' ? '.md' : '.toml';
}

async function confirmDeletion(files: string[]): Promise<boolean> {
  const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
  return new Promise((resolve) => {
    rl.question(`\nRemove ${files.length} orphaned command(s)? (y/N) `, (answer) => {
      rl.close();
      resolve(answer.toLowerCase() === 'y');
    });
  });
}

export function generateSlashCommands(opts: GenerateOptions): GenerateResult[] {
  const skillsDir = opts.skillsDir || resolveSkillsDir();
  const specs = normalizeSkills(skillsDir, opts.platforms);
  const results: GenerateResult[] = [];

  for (const platform of opts.platforms) {
    const outputDir = resolveOutputDir(platform, opts);
    const ext = fileExtension(platform);
    const useAbsolutePaths = opts.global;

    // Render all specs for this platform
    const rendered = new Map<string, string>();
    for (const spec of specs) {
      const filename = `${spec.name}${ext}`;

      if (platform === 'claude-code') {
        // For global installs, rewrite @ references to absolute paths
        const renderSpec = useAbsolutePaths
          ? {
              ...spec,
              prompt: {
                ...spec.prompt,
                executionContext: spec.prompt.executionContext
                  .split('\n')
                  .map((line) => {
                    if (line.startsWith('@')) {
                      const relPath = line.slice(1);
                      return `@${path.resolve(relPath)}`;
                    }
                    return line;
                  })
                  .join('\n'),
              },
            }
          : spec;
        rendered.set(filename, renderClaudeCode(renderSpec));
      } else {
        // Use sourceDir from spec to find files directly
        const mdPath = path.join(skillsDir, spec.sourceDir, 'SKILL.md');
        const yamlPath = path.join(skillsDir, spec.sourceDir, 'skill.yaml');
        const mdContent = fs.existsSync(mdPath) ? fs.readFileSync(mdPath, 'utf-8') : '';
        const yamlContent = fs.existsSync(yamlPath) ? fs.readFileSync(yamlPath, 'utf-8') : '';
        rendered.set(filename, renderGemini(spec, mdContent, yamlContent));
      }
    }

    // Compute sync plan
    const plan = computeSyncPlan(outputDir, rendered);

    if (!opts.dryRun) {
      // Deletion is handled separately (may need interactive confirmation)
      applySyncPlan(outputDir, rendered, plan, false);
    }

    results.push({
      platform,
      added: plan.added,
      updated: plan.updated,
      removed: plan.removed,
      unchanged: plan.unchanged,
      outputDir,
    });
  }

  return results;
}

/**
 * Handle orphan deletion after results are computed.
 * Separated from generateSlashCommands so the core function stays synchronous and testable.
 */
export async function handleOrphanDeletion(
  results: GenerateResult[],
  opts: { yes: boolean; dryRun: boolean }
): Promise<void> {
  if (opts.dryRun) return;

  for (const result of results) {
    if (result.removed.length === 0) continue;

    const shouldDelete = opts.yes || (await confirmDeletion(result.removed));
    if (shouldDelete) {
      for (const filename of result.removed) {
        const filePath = path.join(result.outputDir, filename);
        if (fs.existsSync(filePath)) {
          fs.unlinkSync(filePath);
        }
      }
    }
  }
}

export function createGenerateSlashCommandsCommand(): Command {
  return new Command('generate-slash-commands')
    .description(
      'Generate native slash commands for Claude Code and Gemini CLI from skill metadata'
    )
    .option('--platforms <list>', 'Target platforms (comma-separated)', 'claude-code,gemini-cli')
    .option('--global', 'Write to global config directories', false)
    .option('--output <dir>', 'Custom output directory')
    .option('--skills-dir <path>', 'Skills directory to scan')
    .option('--dry-run', 'Show what would change without writing', false)
    .option('--yes', 'Skip deletion confirmation prompts', false)
    .action(async (opts, cmd) => {
      const globalOpts = cmd.optsWithGlobals();

      // Validate platforms
      const platforms = opts.platforms.split(',').map((p: string) => p.trim());
      for (const p of platforms) {
        if (!VALID_PLATFORMS.includes(p as Platform)) {
          throw new CLIError(
            `Invalid platform "${p}". Valid platforms: ${VALID_PLATFORMS.join(', ')}`,
            ExitCode.VALIDATION_FAILED
          );
        }
      }

      const generateOpts: GenerateOptions = {
        platforms: platforms as Platform[],
        global: opts.global,
        output: opts.output,
        skillsDir: opts.skillsDir ?? '',
        dryRun: opts.dryRun,
        yes: opts.yes,
      };

      try {
        const results = generateSlashCommands(generateOpts);

        if (globalOpts.json) {
          console.log(JSON.stringify(results, null, 2));
          return;
        }

        for (const result of results) {
          console.log(`\n${result.platform} → ${result.outputDir}`);
          if (result.added.length > 0) {
            console.log(`  + ${result.added.length} new: ${result.added.join(', ')}`);
          }
          if (result.updated.length > 0) {
            console.log(`  ~ ${result.updated.length} updated: ${result.updated.join(', ')}`);
          }
          if (result.removed.length > 0) {
            console.log(`  - ${result.removed.length} removed: ${result.removed.join(', ')}`);
          }
          if (result.unchanged.length > 0) {
            console.log(`  = ${result.unchanged.length} unchanged`);
          }
          if (opts.dryRun) {
            console.log('  (dry run — no files written)');
          }
        }

        // Handle orphan deletion (interactive prompt if needed)
        await handleOrphanDeletion(results, { yes: opts.yes, dryRun: opts.dryRun });
      } catch (error) {
        if (error instanceof CLIError) {
          console.error(`Error: ${error.message}`);
          process.exit(error.exitCode);
        }
        console.error(`Error: ${error instanceof Error ? error.message : error}`);
        process.exit(1);
      }
    });
}
```

- [ ] **Step 2: Register the command in index.ts**

Add to `packages/cli/src/index.ts`:

```typescript
import {
  createGenerateSlashCommandsCommand,
  generateSlashCommands,
} from './commands/generate-slash-commands';

// Inside createProgram(), add:
program.addCommand(createGenerateSlashCommandsCommand());

// Add to exports:
export { generateSlashCommands } from './commands/generate-slash-commands';
```

- [ ] **Step 3: Build and verify the command registers**

Run: `cd packages/cli && npx tsup && node dist/index.js generate-slash-commands --help`
Expected: Shows usage with all options (--platforms, --global, --output, --skills-dir, --dry-run, --yes)

- [ ] **Step 4: Commit**

```bash
git add packages/cli/src/commands/generate-slash-commands.ts packages/cli/src/index.ts
git commit -m "feat: add generate-slash-commands CLI command"
```

### Task 9: MCP Server Tool Registration

**Files:**

- Create: `packages/mcp-server/src/tools/generate-slash-commands.ts`
- Modify: `packages/mcp-server/src/server.ts` (register tool)

- [ ] **Step 1: Write the MCP tool definition and handler**

```typescript
// packages/mcp-server/src/tools/generate-slash-commands.ts
import { generateSlashCommands } from '@harness-engineering/cli';
import { Ok } from '@harness-engineering/core';
import { resultToMcpResponse } from '../utils/result-adapter.js';
import type { McpToolResponse } from '../utils/result-adapter.js';

export const generateSlashCommandsDefinition = {
  name: 'generate_slash_commands',
  description:
    'Generate native slash commands for Claude Code and Gemini CLI from harness skill metadata',
  inputSchema: {
    type: 'object' as const,
    properties: {
      platforms: {
        type: 'string',
        description: 'Comma-separated platforms: claude-code,gemini-cli (default: both)',
      },
      global: {
        type: 'boolean',
        description:
          'Write to global config directories (~/.claude/commands/, ~/.gemini/commands/)',
      },
      output: {
        type: 'string',
        description: 'Custom output directory',
      },
      skillsDir: {
        type: 'string',
        description: 'Skills directory to scan',
      },
      dryRun: {
        type: 'boolean',
        description: 'Show what would change without writing files',
      },
    },
  },
};

export async function handleGenerateSlashCommands(input: {
  platforms?: string;
  global?: boolean;
  output?: string;
  skillsDir?: string;
  dryRun?: boolean;
}): Promise<McpToolResponse> {
  const platforms = (input.platforms ?? 'claude-code,gemini-cli')
    .split(',')
    .map((p) => p.trim()) as Array<'claude-code' | 'gemini-cli'>;

  const results = generateSlashCommands({
    platforms,
    global: input.global ?? false,
    output: input.output,
    skillsDir: input.skillsDir ?? '',
    dryRun: input.dryRun ?? false,
    yes: true, // MCP always auto-confirms
  });

  return resultToMcpResponse(Ok(JSON.stringify(results, null, 2)));
}
```

- [ ] **Step 2: Register in server.ts**

Add imports and registration to `packages/mcp-server/src/server.ts`:

```typescript
import {
  generateSlashCommandsDefinition,
  handleGenerateSlashCommands,
} from './tools/generate-slash-commands';

// Add to TOOL_DEFINITIONS array:
generateSlashCommandsDefinition,

// Add to TOOL_HANDLERS record:
generate_slash_commands: handleGenerateSlashCommands,
```

- [ ] **Step 3: Build and verify**

Run: `cd packages/mcp-server && npx tsup`
Expected: Builds without errors

- [ ] **Step 4: Commit**

```bash
git add packages/mcp-server/src/tools/generate-slash-commands.ts packages/mcp-server/src/server.ts
git commit -m "feat: expose generate_slash_commands as MCP tool"
```

---

## Chunk 6: Integration Test and End-to-End Verification

### Task 10: Integration Test

**Files:**

- Create: `packages/cli/src/slash-commands/__tests__/integration.test.ts`

- [ ] **Step 1: Write the integration test**

```typescript
// packages/cli/src/slash-commands/__tests__/integration.test.ts
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import fs from 'node:fs';
import path from 'node:path';
import os from 'node:os';
import { generateSlashCommands } from '../../commands/generate-slash-commands';
import { GENERATED_HEADER_CLAUDE, GENERATED_HEADER_GEMINI } from '../types';

function makeTmpDir(): string {
  return fs.mkdtempSync(path.join(os.tmpdir(), 'slash-cmd-integration-'));
}

const fixturesDir = path.join(__dirname, 'fixtures');

describe('generateSlashCommands integration', () => {
  let tmpDir: string;

  beforeEach(() => {
    tmpDir = makeTmpDir();
  });
  afterEach(() => {
    fs.rmSync(tmpDir, { recursive: true, force: true });
  });

  it('generates Claude Code markdown files from fixture skills', () => {
    const results = generateSlashCommands({
      platforms: ['claude-code'],
      global: false,
      output: tmpDir,
      skillsDir: fixturesDir,
      dryRun: false,
      yes: true,
    });

    expect(results).toHaveLength(1);
    expect(results[0].platform).toBe('claude-code');
    expect(results[0].added.length).toBeGreaterThan(0);

    // Verify a generated file exists and has correct format
    const files = fs.readdirSync(path.join(tmpDir, 'harness'));
    const mdFiles = files.filter((f) => f.endsWith('.md'));
    expect(mdFiles.length).toBeGreaterThan(0);

    const content = fs.readFileSync(path.join(tmpDir, 'harness', mdFiles[0]), 'utf-8');
    expect(content).toContain(GENERATED_HEADER_CLAUDE);
    expect(content).toContain('name: harness:');
  });

  it('generates Gemini CLI TOML files from fixture skills', () => {
    const results = generateSlashCommands({
      platforms: ['gemini-cli'],
      global: false,
      output: tmpDir,
      skillsDir: fixturesDir,
      dryRun: false,
      yes: true,
    });

    expect(results).toHaveLength(1);
    const files = fs.readdirSync(path.join(tmpDir, 'harness'));
    const tomlFiles = files.filter((f) => f.endsWith('.toml'));
    expect(tomlFiles.length).toBeGreaterThan(0);

    const content = fs.readFileSync(path.join(tmpDir, 'harness', tomlFiles[0]), 'utf-8');
    expect(content).toContain(GENERATED_HEADER_GEMINI);
    expect(content).toContain('description = ');
    expect(content).toContain('prompt = """');
  });

  it('dry-run does not write files', () => {
    const results = generateSlashCommands({
      platforms: ['claude-code'],
      global: false,
      output: tmpDir,
      skillsDir: fixturesDir,
      dryRun: true,
      yes: true,
    });

    expect(results[0].added.length).toBeGreaterThan(0);
    expect(fs.existsSync(path.join(tmpDir, 'harness'))).toBe(false);
  });

  it('second run detects unchanged files', () => {
    // First run
    generateSlashCommands({
      platforms: ['claude-code'],
      global: false,
      output: tmpDir,
      skillsDir: fixturesDir,
      dryRun: false,
      yes: true,
    });

    // Second run
    const results = generateSlashCommands({
      platforms: ['claude-code'],
      global: false,
      output: tmpDir,
      skillsDir: fixturesDir,
      dryRun: false,
      yes: true,
    });

    expect(results[0].added).toEqual([]);
    expect(results[0].updated).toEqual([]);
    expect(results[0].unchanged.length).toBeGreaterThan(0);
  });
});
```

- [ ] **Step 2: Run integration tests**

Run: `cd packages/cli && npx vitest run src/slash-commands/__tests__/integration.test.ts`
Expected: All 4 tests PASS

- [ ] **Step 3: Run all slash-commands tests together**

Run: `cd packages/cli && npx vitest run src/slash-commands/`
Expected: All tests PASS (normalize-name, argument-hint, normalize, render-claude-code, render-gemini, sync, integration)

- [ ] **Step 4: Commit**

```bash
git add packages/cli/src/slash-commands/__tests__/integration.test.ts
git commit -m "test: add integration tests for slash command generation"
```

### Task 11: End-to-End Smoke Test

- [ ] **Step 1: Run against real skills**

Run: `cd /Users/cwarner/Projects/harness-engineering && npx harness generate-slash-commands --dry-run`
Expected: Shows sync summary with all 21+ skills listed as "new"

- [ ] **Step 2: Generate for real (project-local)**

Run: `cd /Users/cwarner/Projects/harness-engineering && npx harness generate-slash-commands`
Expected: Creates `agents/commands/claude-code/harness/*.md` and `agents/commands/gemini-cli/harness/*.toml`

- [ ] **Step 3: Verify a generated Claude Code command**

Read one of the generated .md files and verify:

- Has generated header
- Has YAML frontmatter with name, description, allowed-tools
- Has context/objective/execution_context/process sections
- @file references point to valid paths

- [ ] **Step 4: Verify a generated Gemini CLI command**

Read one of the generated .toml files and verify:

- Has generated header
- Has description and prompt fields
- Inlined SKILL.md and skill.yaml content present in execution_context
- Process section uses "follow the SKILL.md workflow provided above directly"

- [ ] **Step 5: Run idempotency check**

Run: `npx harness generate-slash-commands`
Expected: All files show as "unchanged"

- [ ] **Step 6: Final commit**

```bash
git add agents/commands/
git commit -m "feat: generate slash commands for all harness skills"
```
