Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 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
5 changes: 4 additions & 1 deletion docs/supported-tools.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ You can enable expanded workflows (`new`, `continue`, `ff`, `verify`, `bulk-arch
| Factory Droid (`factory`) | `.factory/skills/openspec-*/SKILL.md` | `.factory/commands/opsx-<id>.md` |
| Gemini CLI (`gemini`) | `.gemini/skills/openspec-*/SKILL.md` | `.gemini/commands/opsx/<id>.toml` |
| GitHub Copilot (`github-copilot`) | `.github/skills/openspec-*/SKILL.md` | `.github/prompts/opsx-<id>.prompt.md`\*\* |
| Hermes Agent (`hermes`) | `.hermes/skills/openspec-*/SKILL.md`\*\*\* | Not generated (no command adapter; use skill-based `/openspec-*` invocations) |
| iFlow (`iflow`) | `.iflow/skills/openspec-*/SKILL.md` | `.iflow/commands/opsx-<id>.md` |
| Junie (`junie`) | `.junie/skills/openspec-*/SKILL.md` | `.junie/commands/opsx-<id>.md` |
| Kilo Code (`kilocode`) | `.kilocode/skills/openspec-*/SKILL.md` | `.kilocode/workflows/opsx-<id>.md` |
Expand All @@ -58,6 +59,8 @@ You can enable expanded workflows (`new`, `continue`, `ff`, `verify`, `bulk-arch

\*\* GitHub Copilot prompt files are recognized as custom slash commands in IDE extensions (VS Code, JetBrains, Visual Studio). Copilot CLI does not currently consume `.github/prompts/*.prompt.md` directly.

\*\*\* Hermes loads skills from `~/.hermes/skills/` by default. To use project-local OpenSpec skills, add the project `.hermes/skills/` directory to `skills.external_dirs` in `~/.hermes/config.yaml`; Hermes then exposes skills with user-facing slash invocations such as `/openspec-propose`.

## Non-Interactive Setup

For CI/CD or scripted setup, use `--tools` (and optionally `--profile`):
Expand All @@ -76,7 +79,7 @@ openspec init --tools none
openspec init --profile core
```

**Available tool IDs (`--tools`):** `amazon-q`, `antigravity`, `auggie`, `bob`, `claude`, `cline`, `codex`, `forgecode`, `codebuddy`, `continue`, `costrict`, `crush`, `cursor`, `factory`, `gemini`, `github-copilot`, `iflow`, `junie`, `kilocode`, `kimi`, `kiro`, `lingma`, `vibe`, `oh-my-pi`, `opencode`, `pi`, `qoder`, `qwen`, `roocode`, `trae`, `windsurf`
**Available tool IDs (`--tools`):** `amazon-q`, `antigravity`, `auggie`, `bob`, `claude`, `cline`, `codex`, `forgecode`, `codebuddy`, `continue`, `costrict`, `crush`, `cursor`, `factory`, `gemini`, `github-copilot`, `hermes`, `iflow`, `junie`, `kilocode`, `kimi`, `kiro`, `lingma`, `vibe`, `oh-my-pi`, `opencode`, `pi`, `qoder`, `qwen`, `roocode`, `trae`, `windsurf`

## Workflow-Dependent Installation

Expand Down
7 changes: 7 additions & 0 deletions openspec/specs/ai-tool-paths/spec.md
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,13 @@ The `AI_TOOLS` array SHALL include `skillsDir` for tools that support the Agent
- **WHEN** looking up the `kimi` tool
- **THEN** `skillsDir` SHALL be `.kimi`

#### Scenario: Hermes Agent paths defined

- **WHEN** looking up the `hermes` tool
- **THEN** `skillsDir` SHALL be `.hermes`
- **AND** `setupNote` SHALL explain that project `.hermes/skills` must be added to `skills.external_dirs` in `~/.hermes/config.yaml`
- **AND** `openspec init` and `openspec update` SHALL display the note whenever `hermes` is configured

#### Scenario: Tools without skillsDir

- **WHEN** a tool has no `skillsDir` defined
Expand Down
2 changes: 2 additions & 0 deletions src/core/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ export interface AIToolOption {
successLabel?: string;
skillsDir?: string; // e.g., '.claude' - /skills suffix per Agent Skills spec
detectionPaths?: string[]; // Override skillsDir for auto-detection; any path existing triggers detection
setupNote?: string; // Manual setup required before the tool picks up generated files; shown after init/update
}

export const AI_TOOLS: AIToolOption[] = [
Expand All @@ -35,6 +36,7 @@ export const AI_TOOLS: AIToolOption[] = [
{ name: 'Factory Droid', value: 'factory', available: true, successLabel: 'Factory Droid', skillsDir: '.factory' },
{ name: 'Gemini CLI', value: 'gemini', available: true, successLabel: 'Gemini CLI', skillsDir: '.gemini' },
{ name: 'GitHub Copilot', value: 'github-copilot', available: true, successLabel: 'GitHub Copilot', skillsDir: '.github', detectionPaths: ['.github/copilot-instructions.md', '.github/instructions', '.github/workflows/copilot-setup-steps.yml', '.github/prompts', '.github/agents', '.github/skills', '.github/.mcp.json'] },
{ name: 'Hermes Agent', value: 'hermes', available: true, successLabel: 'Hermes Agent', skillsDir: '.hermes', detectionPaths: ['.hermes', 'HERMES.md', '.hermes.md'], setupNote: "Hermes only loads skills from ~/.hermes/skills by default. Add this project's .hermes/skills directory to skills.external_dirs in ~/.hermes/config.yaml so Hermes picks up the generated OpenSpec skills." },
{ name: 'iFlow', value: 'iflow', available: true, successLabel: 'iFlow', skillsDir: '.iflow' },
{ name: 'Junie', value: 'junie', available: true, successLabel: 'Junie', skillsDir: '.junie' },
{ name: 'Kilo Code', value: 'kilocode', available: true, successLabel: 'Kilo Code', skillsDir: '.kilocode' },
Expand Down
8 changes: 8 additions & 0 deletions src/core/init.ts
Original file line number Diff line number Diff line change
Expand Up @@ -709,6 +709,14 @@ export class InitCommand {
console.log(chalk.dim(`Removed: ${results.removedSkillCount} skill directories (delivery: commands)`));
}

// Show manual setup notes for tools that need extra configuration
for (const tool of successfulTools) {
const setupNote = AI_TOOLS.find((t) => t.value === tool.value)?.setupNote;
if (setupNote) {
console.log(chalk.yellow(`Setup required for ${tool.name}: ${setupNote}`));
}
}

// Config status
if (configStatus === 'created') {
console.log(`Config: openspec/config.yaml (schema: ${DEFAULT_SCHEMA})`);
Expand Down
15 changes: 15 additions & 0 deletions src/core/update.ts
Original file line number Diff line number Diff line change
Expand Up @@ -157,6 +157,7 @@ export class UpdateCommand {
this.detectNewTools(resolvedProjectPath, configuredTools);
this.displayExtraWorkflowsNote(resolvedProjectPath, configuredTools, desiredWorkflows);
this.displayOldCoreCustomProfileNote(profile, globalConfig.workflows);
this.displaySetupNotes(configuredTools);
return;
}

Expand Down Expand Up @@ -285,6 +286,7 @@ export class UpdateCommand {
// 14. Display note about extra workflows not in profile
this.displayExtraWorkflowsNote(resolvedProjectPath, configuredAndNewTools, desiredWorkflows);
this.displayOldCoreCustomProfileNote(profile, globalConfig.workflows);
this.displaySetupNotes(configuredAndNewTools);

// 15. List affected tools
if (updatedTools.length > 0) {
Expand Down Expand Up @@ -332,6 +334,19 @@ export class UpdateCommand {
}
}

/**
* Shows manual setup notes for configured tools that need extra
* configuration before they pick up generated files.
*/
private displaySetupNotes(toolIds: string[]): void {
for (const toolId of toolIds) {
const tool = AI_TOOLS.find((t) => t.value === toolId);
if (tool?.setupNote) {
console.log(chalk.yellow(`Setup required for ${tool.name}: ${tool.setupNote}`));
}
}
}

/**
* Detects new tool directories that aren't currently configured and displays a hint.
*/
Expand Down
36 changes: 36 additions & 0 deletions test/core/available-tools.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -140,6 +140,42 @@ describe('available-tools', () => {
expect(toolValues).toContain('github-copilot');
});

it('should detect Hermes Agent when HERMES.md exists', async () => {
await fs.writeFile(path.join(testDir, 'HERMES.md'), '');

const tools = getAvailableTools(testDir);
const hermesTool = tools.find((t) => t.value === 'hermes');

expect(hermesTool).toMatchObject({
name: 'Hermes Agent',
skillsDir: '.hermes',
});
});

it('should detect Hermes Agent when .hermes.md exists', async () => {
await fs.writeFile(path.join(testDir, '.hermes.md'), '');

const tools = getAvailableTools(testDir);
const toolValues = tools.map((t) => t.value);
expect(toolValues).toContain('hermes');
});

it('should detect Hermes Agent when .hermes directory exists', async () => {
await fs.mkdir(path.join(testDir, '.hermes'), { recursive: true });

const tools = getAvailableTools(testDir);
const toolValues = tools.map((t) => t.value);
expect(toolValues).toContain('hermes');
});

it('should not detect Hermes Agent from plain CONTEXT.md', async () => {
await fs.writeFile(path.join(testDir, 'CONTEXT.md'), '');

const tools = getAvailableTools(testDir);
const toolValues = tools.map((t) => t.value);
expect(toolValues).not.toContain('hermes');
});

it('should still use skillsDir detection for tools without detectionPaths', async () => {
// Claude Code has no detectionPaths, so .claude/ directory should still work
await fs.mkdir(path.join(testDir, '.claude'), { recursive: true });
Expand Down
5 changes: 5 additions & 0 deletions test/core/command-generation/registry.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,11 @@ describe('command-generation/registry', () => {
expect(adapter).toBeUndefined();
});

it('should return undefined for skills-only tools without adapters', () => {
expect(CommandAdapterRegistry.get('hermes')).toBeUndefined();
expect(CommandAdapterRegistry.get('kimi')).toBeUndefined();
});

it('should return undefined for empty string', () => {
const adapter = CommandAdapterRegistry.get('');
expect(adapter).toBeUndefined();
Expand Down
29 changes: 29 additions & 0 deletions test/core/init.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -194,6 +194,35 @@ describe('InitCommand', () => {
).toBe(true);
});

it('should support Hermes Agent as an adapterless skills-only tool with a setup note', async () => {
saveGlobalConfig({
featureFlags: {},
profile: 'core',
delivery: 'both',
});

const initCommand = new InitCommand({ tools: 'hermes', force: true });
await initCommand.execute(testDir);

const skillFile = path.join(testDir, '.hermes', 'skills', 'openspec-explore', 'SKILL.md');
expect(await fileExists(skillFile)).toBe(true);

const commandsDir = path.join(testDir, '.hermes', 'commands');
expect(await directoryExists(commandsDir)).toBe(false);

const logCalls = (console.log as unknown as { mock: { calls: unknown[][] } }).mock.calls.flat().map(String);
expect(
logCalls.some(
(entry) => entry.includes('Commands skipped for: hermes') && entry.includes('(no adapter)'),
),
).toBe(true);
expect(
logCalls.some(
(entry) => entry.includes('Setup required for Hermes Agent') && entry.includes('skills.external_dirs'),
),
).toBe(true);
});

it('should create both skills and commands for Trae with adapter', async () => {
saveGlobalConfig({
configuredTools: [],
Expand Down
41 changes: 41 additions & 0 deletions test/core/update.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -140,6 +140,47 @@ Old instructions content
consoleSpy.mockRestore();
});

it('should show the Hermes setup note when updating a configured Hermes tool', async () => {
const exploreSkillDir = path.join(testDir, '.hermes', 'skills', 'openspec-explore');
await fs.mkdir(exploreSkillDir, { recursive: true });
await fs.writeFile(
path.join(exploreSkillDir, 'SKILL.md'),
`---\nname: openspec-explore\nmetadata:\n author: openspec\n version: "0.9"\n---\n\nOld instructions content\n`
);

const consoleSpy = vi.spyOn(console, 'log');

await updateCommand.execute(testDir);

const logCalls = consoleSpy.mock.calls.flat().map(String);
expect(
logCalls.some(
(entry) => entry.includes('Setup required for Hermes Agent') && entry.includes('skills.external_dirs'),
),
).toBe(true);

consoleSpy.mockRestore();
});

it('should show the Hermes setup note even when Hermes is already up to date', async () => {
const initCommand = new InitCommand({ tools: 'hermes', force: true });
await initCommand.execute(testDir);

const consoleSpy = vi.spyOn(console, 'log');

await updateCommand.execute(testDir);

const logCalls = consoleSpy.mock.calls.flat().map(String);
expect(logCalls.some((entry) => entry.includes('up to date'))).toBe(true);
expect(
logCalls.some(
(entry) => entry.includes('Setup required for Hermes Agent') && entry.includes('skills.external_dirs'),
),
).toBe(true);

consoleSpy.mockRestore();
});

it('should update core profile skill files when tool is configured', async () => {
// Set up a configured tool with one skill directory
const skillsDir = path.join(testDir, '.claude', 'skills');
Expand Down
Loading