Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -149,3 +149,7 @@ CLAUDE.md
# Pnpm
.pnpm-store/
result

# OpenCode
.opencode/
opencode.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-01-30
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
# opencode-command-references

Transform /opsx: to /opsx- in both commands and skills for OpenCode
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
## Context

OpenCode is one of many supported AI tools. Each tool has:
- A **command adapter** (in `src/core/command-generation/adapters/`) for generating tool-specific command files
- **Skills** generated via `generateSkillContent()` in `src/core/shared/skill-generation.ts`

Currently:
- Commands go through the adapter system which can transform content per-tool
- Skills use a single shared function with no tool-specific transformation

The templates in `src/core/templates/skill-templates.ts` use Claude's colon-based format (`/opsx:new`) as the canonical format. Tools that use different formats need transformation at generation time.

## Goals / Non-Goals

**Goals:**
- Transform all `/opsx:` command references to `/opsx-` for OpenCode in both commands and skills
- Create a shared, reusable transformation utility
- Keep the transformation opt-in via a callback parameter (not hard-coded tool detection)

**Non-Goals:**
- Modifying the canonical template format (templates stay with `/opsx:`)
- Applying transformation to other tools (only OpenCode for now)
- Creating a full adapter system for skills (overkill for current needs)

## Decisions

### Decision 1: Shared Utility Function

**Choice**: Create `transformToHyphenCommands()` in `src/utils/command-references.ts`

**Rationale**:
- Single source of truth for the transformation logic
- Can be used by both command adapter and skill generation
- Easy to test in isolation
- Follows existing utils pattern in the codebase

**Alternatives considered**:
- Inline the transformation in each location - Duplicates logic, harder to maintain

### Decision 2: Callback Parameter for Skill Generation

**Choice**: Add optional `transformInstructions?: (instructions: string) => string` parameter to `generateSkillContent()`

**Rationale**:
- Flexible - callers define the transformation, not the generation function
- No coupling - `generateSkillContent()` doesn't need to know about tool formats
- Extensible - could support other transformations in the future
- Follows inversion of control principle

**Alternatives considered**:
- Add tool ID parameter and switch on it - Creates coupling, harder to extend
- Create skill adapter system parallel to commands - Over-engineering for current needs
- Transform in templates directly - Breaks single-source-of-truth principle

### Decision 3: Apply at Generation Sites

**Choice**: Pass transformer in `init.ts` and `update.ts` when `tool.value === 'opencode'`

**Rationale**:
- These are the only two places that generate skills
- Simple conditional check, no new abstractions needed
- Easy to extend to other tools if needed later

## Risks / Trade-offs

| Risk | Mitigation |
|------|------------|
| Other `/opsx:` patterns exist that shouldn't be transformed | All occurrences in templates are command invocations - verified by inspection |
| Future tools may need same transformation | Utility is shared and easy to reuse; can add to other tools' generation |
| Callback adds complexity to function signature | Optional parameter with sensible default (no transformation) |
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
## Why

OpenCode uses hyphen-based command syntax (`/opsx-new`) but our templates contain colon-based references (`/opsx:new`). This creates inconsistency where generated command files and skill files contain references that don't match the actual command invocation syntax, confusing both the AI and users.

## What Changes

- Create a shared transformation utility (`transformToHyphenCommands`) for converting `/opsx:` to `/opsx-`
- Update the OpenCode command adapter to transform body text using this utility
- Add an optional `transformInstructions` callback parameter to `generateSkillContent()`
- Update `init.ts` and `update.ts` to pass the transformer when generating skills for OpenCode

## Capabilities

### New Capabilities

None - this is a bug fix, not a new capability.

### Modified Capabilities

None - no spec-level behavior changes. This is an implementation fix in the OpenCode adapter and skill generation that doesn't change any external requirements or contracts.

## Impact

- **Code**:
- `src/utils/command-references.ts` (new file)
- `src/utils/index.ts` (export)
- `src/core/shared/skill-generation.ts` (add callback parameter)
- `src/core/command-generation/adapters/opencode.ts` (use transformer)
- `src/core/init.ts` (pass transformer for OpenCode)
- `src/core/update.ts` (pass transformer for OpenCode)
- **Users**: OpenCode users will see correct `/opsx-` command references in both generated command files AND skill files
- **Other tools**: No impact - transformation only applies to OpenCode
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
# No Spec Changes

This is a bug fix that doesn't modify any external requirements or contracts.

The proposal's Capabilities section indicates:
- **New Capabilities**: None
- **Modified Capabilities**: None

No spec files are needed for this implementation-only fix.
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
## 1. Implementation

- [x] 1.1 Create `src/utils/command-references.ts` with `transformToHyphenCommands()` function
- [x] 1.2 Export `transformToHyphenCommands` from `src/utils/index.ts`
- [x] 1.3 Update `generateSkillContent()` in `src/core/shared/skill-generation.ts` to accept optional `transformInstructions` callback
- [x] 1.4 Update OpenCode adapter in `src/core/command-generation/adapters/opencode.ts` to use `transformToHyphenCommands()` for body text
- [x] 1.5 Update `init.ts` to pass transformer when generating skills for OpenCode
- [x] 1.6 Update `update.ts` to pass transformer when generating skills for OpenCode

## 2. Testing

- [x] 2.1 Create `test/utils/command-references.test.ts` with unit tests for `transformToHyphenCommands()`
- [x] 2.2 Add test to `test/core/command-generation/adapters.test.ts` for OpenCode body transformation
- [x] 2.3 Add test to `test/core/shared/skill-generation.test.ts` for transformer callback

## 3. Verification

- [x] 3.1 Run `npx vitest run test/utils/command-references.test.ts test/core/command-generation/adapters.test.ts test/core/shared/skill-generation.test.ts` to ensure tests pass
- [x] 3.2 Run `pnpm run build` to ensure no TypeScript errors
- [x] 3.3 Run `openspec init --tools opencode` in a temp directory and verify:
- Command files in `.opencode/command/` contain `/opsx-` references (not `/opsx:`)
- Skill files in `.opencode/skills/` contain `/opsx-` references (not `/opsx:`)
6 changes: 5 additions & 1 deletion src/core/command-generation/adapters/opencode.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@

import path from 'path';
import type { CommandContent, ToolCommandAdapter } from '../types.js';
import { transformToHyphenCommands } from '../../../utils/command-references.js';

/**
* OpenCode adapter for command generation.
Expand All @@ -20,11 +21,14 @@ export const opencodeAdapter: ToolCommandAdapter = {
},

formatFile(content: CommandContent): string {
// Transform command references from colon to hyphen format for OpenCode
const transformedBody = transformToHyphenCommands(content.body);

return `---
description: ${content.description}
---
${content.body}
${transformedBody}
`;
},
};
5 changes: 4 additions & 1 deletion src/core/init.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import ora from 'ora';
import * as fs from 'fs';
import { createRequire } from 'module';
import { FileSystemUtils } from '../utils/file-system.js';
import { transformToHyphenCommands } from '../utils/command-references.js';
import {
AI_TOOLS,
OPENSPEC_DIR_NAME,
Expand Down Expand Up @@ -440,7 +441,9 @@ export class InitCommand {
const skillFile = path.join(skillDir, 'SKILL.md');

// Generate SKILL.md content with YAML frontmatter including generatedBy
const skillContent = generateSkillContent(template, OPENSPEC_VERSION);
// Use hyphen-based command references for OpenCode
const transformer = tool.value === 'opencode' ? transformToHyphenCommands : undefined;
const skillContent = generateSkillContent(template, OPENSPEC_VERSION, transformer);

// Write the skill file
await FileSystemUtils.writeFile(skillFile, skillContent);
Expand Down
10 changes: 8 additions & 2 deletions src/core/shared/skill-generation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -101,11 +101,17 @@ export function getCommandContents(): CommandContent[] {
*
* @param template - The skill template
* @param generatedByVersion - The OpenSpec version to embed in the file
* @param transformInstructions - Optional callback to transform the instructions content
*/
export function generateSkillContent(
template: SkillTemplate,
generatedByVersion: string
generatedByVersion: string,
transformInstructions?: (instructions: string) => string
): string {
const instructions = transformInstructions
? transformInstructions(template.instructions)
: template.instructions;

return `---
name: ${template.name}
description: ${template.description}
Expand All @@ -117,6 +123,6 @@ metadata:
generatedBy: "${generatedByVersion}"
---
${template.instructions}
${instructions}
`;
}
9 changes: 7 additions & 2 deletions src/core/update.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import chalk from 'chalk';
import ora from 'ora';
import { createRequire } from 'module';
import { FileSystemUtils } from '../utils/file-system.js';
import { transformToHyphenCommands } from '../utils/command-references.js';
import { AI_TOOLS, OPENSPEC_DIR_NAME } from './config.js';
import {
generateCommands,
Expand Down Expand Up @@ -117,7 +118,9 @@ export class UpdateCommand {
const skillDir = path.join(skillsDir, dirName);
const skillFile = path.join(skillDir, 'SKILL.md');

const skillContent = generateSkillContent(template, OPENSPEC_VERSION);
// Use hyphen-based command references for OpenCode
const transformer = tool.value === 'opencode' ? transformToHyphenCommands : undefined;
const skillContent = generateSkillContent(template, OPENSPEC_VERSION, transformer);
Comment on lines +121 to +123
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This approach works for the immediate fix, but we should look into a more robust way to handle tool-specific transformations as we add support for more tools, similar to how it's handled in src/core/command-generation/.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@webrgp Yeah you're right, it's something I mentioned here as well: #603 (comment)

Didn't think this would bite us back so soon. The way you've done it makes sense, but yes we need to find a better way to handle these transformations between different coding agents.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, totally agree! This was a quick fix to unblock things, but it's definitely not the long-term solution.

I like the idea of mirroring what we already have for commands - basically a skill adapter system that sits alongside the command adapters. Each tool could have its own adapter that knows how to transform content if needed. That way we keep all the tool-specific quirks in one place instead of sprinkling if (tool === 'opencode') checks everywhere.

Happy to open an issue or start a change for this if you want!

await FileSystemUtils.writeFile(skillFile, skillContent);
}

Expand Down Expand Up @@ -366,7 +369,9 @@ export class UpdateCommand {
const skillDir = path.join(skillsDir, dirName);
const skillFile = path.join(skillDir, 'SKILL.md');

const skillContent = generateSkillContent(template, OPENSPEC_VERSION);
// Use hyphen-based command references for OpenCode
const transformer = tool.value === 'opencode' ? transformToHyphenCommands : undefined;
const skillContent = generateSkillContent(template, OPENSPEC_VERSION, transformer);
await FileSystemUtils.writeFile(skillFile, skillContent);
}

Expand Down
20 changes: 20 additions & 0 deletions src/utils/command-references.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
/**
* Command Reference Utilities
*
* Utilities for transforming command references to tool-specific formats.
*/

/**
* Transforms colon-based command references to hyphen-based format.
* Converts `/opsx:` patterns to `/opsx-` for tools that use hyphen syntax.
*
* @param text - The text containing command references
* @returns Text with command references transformed to hyphen format
*
* @example
* transformToHyphenCommands('/opsx:new') // returns '/opsx-new'
* transformToHyphenCommands('Use /opsx:apply to implement') // returns 'Use /opsx-apply to implement'
*/
export function transformToHyphenCommands(text: string): string {
return text.replace(/\/opsx:/g, '/opsx-');
}
5 changes: 4 additions & 1 deletion src/utils/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,4 +12,7 @@ export {
} from './change-metadata.js';

// File system utilities
export { FileSystemUtils, removeMarkerBlock } from './file-system.js';
export { FileSystemUtils, removeMarkerBlock } from './file-system.js';

// Command reference utilities
export { transformToHyphenCommands } from './command-references.js';
27 changes: 27 additions & 0 deletions test/core/command-generation/adapters.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -453,6 +453,33 @@ describe('command-generation/adapters', () => {
expect(output).toContain('---\n\n');
expect(output).toContain('This is the command body.');
});

it('should transform colon-based command references to hyphen-based', () => {
const contentWithCommands: CommandContent = {
...sampleContent,
body: 'Use /opsx:new to start, then /opsx:apply to implement.',
};
const output = opencodeAdapter.formatFile(contentWithCommands);
expect(output).toContain('/opsx-new');
expect(output).toContain('/opsx-apply');
expect(output).not.toContain('/opsx:new');
expect(output).not.toContain('/opsx:apply');
});

it('should handle multiple command references in body', () => {
const contentWithMultipleCommands: CommandContent = {
...sampleContent,
body: `/opsx:explore for ideas
/opsx:new to create
/opsx:continue to proceed
/opsx:apply to implement`,
};
const output = opencodeAdapter.formatFile(contentWithMultipleCommands);
expect(output).toContain('/opsx-explore');
expect(output).toContain('/opsx-new');
expect(output).toContain('/opsx-continue');
expect(output).toContain('/opsx-apply');
});
});

describe('qoderAdapter', () => {
Expand Down
42 changes: 42 additions & 0 deletions test/core/shared/skill-generation.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -177,5 +177,47 @@ describe('skill-generation', () => {

expect(content).toMatch(/---\n\nBody content\n$/);
});

it('should apply transformInstructions callback when provided', () => {
const template = {
name: 'transform-test',
description: 'Test transform callback',
instructions: 'Use /opsx:new to start and /opsx:apply to implement.',
};

const transformer = (text: string) => text.replace(/\/opsx:/g, '/opsx-');
const content = generateSkillContent(template, '0.23.0', transformer);

expect(content).toContain('/opsx-new');
expect(content).toContain('/opsx-apply');
expect(content).not.toContain('/opsx:new');
expect(content).not.toContain('/opsx:apply');
});

it('should not transform instructions when callback is undefined', () => {
const template = {
name: 'no-transform-test',
description: 'Test without transform',
instructions: 'Use /opsx:new to start.',
};

const content = generateSkillContent(template, '0.23.0', undefined);

expect(content).toContain('/opsx:new');
});

it('should support custom transformInstructions logic', () => {
const template = {
name: 'custom-transform',
description: 'Test custom transform',
instructions: 'Some PLACEHOLDER text here.',
};

const customTransformer = (text: string) => text.replace('PLACEHOLDER', 'REPLACED');
const content = generateSkillContent(template, '0.23.0', customTransformer);

expect(content).toContain('Some REPLACED text here.');
expect(content).not.toContain('PLACEHOLDER');
});
});
});
Loading
Loading