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
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 @@ -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
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 @@ -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})`);
Expand Down
15 changes: 15 additions & 0 deletions src/core/update.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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.
*/
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 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 });
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 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');
Expand Down
Loading