diff --git a/docs/supported-tools.md b/docs/supported-tools.md index 8a54f26bba..8eec28ad15 100644 --- a/docs/supported-tools.md +++ b/docs/supported-tools.md @@ -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-.md` | | Gemini CLI (`gemini`) | `.gemini/skills/openspec-*/SKILL.md` | `.gemini/commands/opsx/.toml` | | GitHub Copilot (`github-copilot`) | `.github/skills/openspec-*/SKILL.md` | `.github/prompts/opsx-.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-.md` | | Junie (`junie`) | `.junie/skills/openspec-*/SKILL.md` | `.junie/commands/opsx-.md` | | Kilo Code (`kilocode`) | `.kilocode/skills/openspec-*/SKILL.md` | `.kilocode/workflows/opsx-.md` | @@ -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`): @@ -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 diff --git a/openspec/specs/ai-tool-paths/spec.md b/openspec/specs/ai-tool-paths/spec.md index 4bbce93d1f..4394812570 100644 --- a/openspec/specs/ai-tool-paths/spec.md +++ b/openspec/specs/ai-tool-paths/spec.md @@ -43,6 +43,13 @@ The `AI_TOOLS` array SHALL include `skillsDir` for tools that support the Agent - **THEN** `skillsDir` SHALL be `.kimi-code` - **AND** OpenSpec-managed skills remaining under the legacy `.kimi/skills` directory SHALL be migrated to `.kimi-code/skills` during init and update, preserving user files +#### 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 diff --git a/src/core/config.ts b/src/core/config.ts index 210ef24ef4..081424ea8a 100644 --- a/src/core/config.ts +++ b/src/core/config.ts @@ -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[] = [ @@ -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' }, diff --git a/src/core/init.ts b/src/core/init.ts index d2832e64c1..2d17aefdf2 100644 --- a/src/core/init.ts +++ b/src/core/init.ts @@ -713,6 +713,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})`); diff --git a/src/core/update.ts b/src/core/update.ts index 4919e31084..e75f4471ee 100644 --- a/src/core/update.ts +++ b/src/core/update.ts @@ -164,6 +164,7 @@ export class UpdateCommand { this.detectNewTools(resolvedProjectPath, configuredTools); this.displayExtraWorkflowsNote(resolvedProjectPath, configuredTools, desiredWorkflows); this.displayMissingCoreWorkflowsNote(profile, globalConfig.workflows); + this.displaySetupNotes(configuredTools); return; } @@ -292,6 +293,7 @@ export class UpdateCommand { // 14. Display note about extra workflows not in profile this.displayExtraWorkflowsNote(resolvedProjectPath, configuredAndNewTools, desiredWorkflows); this.displayMissingCoreWorkflowsNote(profile, globalConfig.workflows); + this.displaySetupNotes(configuredAndNewTools); // 15. List affected tools if (updatedTools.length > 0) { @@ -339,6 +341,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. */ diff --git a/test/core/available-tools.test.ts b/test/core/available-tools.test.ts index 13a3fd7cd1..e641d19f06 100644 --- a/test/core/available-tools.test.ts +++ b/test/core/available-tools.test.ts @@ -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 }); diff --git a/test/core/command-generation/registry.test.ts b/test/core/command-generation/registry.test.ts index 14165ff51b..8324b3f8a1 100644 --- a/test/core/command-generation/registry.test.ts +++ b/test/core/command-generation/registry.test.ts @@ -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(); diff --git a/test/core/init.test.ts b/test/core/init.test.ts index a07ad10753..2aed96eee0 100644 --- a/test/core/init.test.ts +++ b/test/core/init.test.ts @@ -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 migrate OpenSpec skills from legacy .kimi to .kimi-code during init', async () => { const legacySkillDir = path.join(testDir, '.kimi', 'skills', 'openspec-explore'); await fs.mkdir(legacySkillDir, { recursive: true }); diff --git a/test/core/update.test.ts b/test/core/update.test.ts index 1e58caea5a..29211de4f3 100644 --- a/test/core/update.test.ts +++ b/test/core/update.test.ts @@ -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 migrate OpenSpec skills from legacy .kimi to .kimi-code, preserving user files', async () => { // Managed skill in the legacy Kimi CLI location const legacySkillDir = path.join(testDir, '.kimi', 'skills', 'openspec-explore');