Skip to content

Commit c417330

Browse files
fix(skills): scope completions by session flavor (#667)
1 parent 04d3d02 commit c417330

6 files changed

Lines changed: 252 additions & 37 deletions

File tree

cli/src/modules/common/handlers/skills.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,11 +5,11 @@ import { listSkills, type ListSkillsRequest, type ListSkillsResponse } from '../
55
import { getErrorMessage, rpcError } from '../rpcResponses'
66

77
export function registerSkillsHandlers(rpcHandlerManager: RpcHandlerManager, workingDirectory: string): void {
8-
rpcHandlerManager.registerHandler<ListSkillsRequest, ListSkillsResponse>(RPC_METHODS.ListSkills, async () => {
8+
rpcHandlerManager.registerHandler<ListSkillsRequest, ListSkillsResponse>(RPC_METHODS.ListSkills, async (request) => {
99
logger.debug('List skills request')
1010

1111
try {
12-
const skills = await listSkills(workingDirectory)
12+
const skills = await listSkills(workingDirectory, { flavor: request.flavor })
1313
return { success: true, skills }
1414
} catch (error) {
1515
logger.debug('Failed to list skills:', error)

cli/src/modules/common/skills.test.ts

Lines changed: 144 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -18,13 +18,17 @@ async function writeSkill(skillDir: string, name: string, description: string):
1818

1919
describe('listSkills', () => {
2020
const originalHome = process.env.HOME
21+
const originalClaudeConfigDir = process.env.CLAUDE_CONFIG_DIR
22+
const originalCodexHome = process.env.CODEX_HOME
2123
let sandboxDir: string
2224
let homeDir: string
2325

2426
beforeEach(async () => {
2527
sandboxDir = await mkdtemp(join(tmpdir(), 'hapi-skills-'))
2628
homeDir = join(sandboxDir, 'home')
2729
process.env.HOME = homeDir
30+
delete process.env.CLAUDE_CONFIG_DIR
31+
delete process.env.CODEX_HOME
2832
await mkdir(homeDir, { recursive: true })
2933
})
3034

@@ -35,6 +39,18 @@ describe('listSkills', () => {
3539
process.env.HOME = originalHome
3640
}
3741

42+
if (originalClaudeConfigDir === undefined) {
43+
delete process.env.CLAUDE_CONFIG_DIR
44+
} else {
45+
process.env.CLAUDE_CONFIG_DIR = originalClaudeConfigDir
46+
}
47+
48+
if (originalCodexHome === undefined) {
49+
delete process.env.CODEX_HOME
50+
} else {
51+
process.env.CODEX_HOME = originalCodexHome
52+
}
53+
3854
await rm(sandboxDir, { recursive: true, force: true })
3955
})
4056

@@ -69,16 +85,139 @@ describe('listSkills', () => {
6985
expect(skills.find((s) => s.name === 'alpha')?.description).toBe('Alpha from agents')
7086
})
7187

72-
it('lists user skills from ~/.codex/skills including Codex bundled system skills', async () => {
88+
it('lists user skills from ~/.codex/skills including Codex bundled system skills for Codex', async () => {
7389
await writeSkill(join(homeDir, '.agents', 'skills', 'amis'), 'amis', 'AMIS guide')
7490
await writeSkill(join(homeDir, '.codex', 'skills', 'hello-agents'), 'helloagents', 'Main skill')
7591
await writeSkill(join(homeDir, '.codex', 'skills', '.system', 'skill-creator'), 'skill-creator', 'Create skills')
7692

77-
const skills = await listSkills()
93+
const skills = await listSkills(undefined, { flavor: 'codex' })
7894

7995
expect(skills.map((skill) => skill.name)).toEqual(['amis', 'helloagents', 'skill-creator'])
8096
})
8197

98+
it('scopes user skills to the requested flavor', async () => {
99+
await writeSkill(join(homeDir, '.agents', 'skills', 'shared'), 'shared', 'Shared skill')
100+
await writeSkill(join(homeDir, '.claude', 'skills', 'claude-only'), 'claude-only', 'Claude skill')
101+
await writeSkill(join(homeDir, '.codex', 'skills', 'codex-only'), 'codex-only', 'Codex skill')
102+
103+
const claudeSkills = await listSkills(undefined, { flavor: 'claude' })
104+
const codexSkills = await listSkills(undefined, { flavor: 'codex' })
105+
106+
expect(claudeSkills.map((skill) => skill.name)).toEqual(['claude-only', 'shared'])
107+
expect(codexSkills.map((skill) => skill.name)).toEqual(['codex-only', 'shared'])
108+
})
109+
110+
it('uses configured Claude and Codex homes for agent-specific user skills', async () => {
111+
const claudeConfigDir = join(sandboxDir, 'custom-claude')
112+
const codexHome = join(sandboxDir, 'custom-codex')
113+
process.env.CLAUDE_CONFIG_DIR = claudeConfigDir
114+
process.env.CODEX_HOME = codexHome
115+
116+
await writeSkill(join(homeDir, '.agents', 'skills', 'shared'), 'shared', 'Shared skill')
117+
await writeSkill(join(homeDir, '.claude', 'skills', 'default-claude'), 'default-claude', 'Default Claude skill')
118+
await writeSkill(join(homeDir, '.codex', 'skills', 'default-codex'), 'default-codex', 'Default Codex skill')
119+
await writeSkill(join(claudeConfigDir, 'skills', 'custom-claude'), 'custom-claude', 'Custom Claude skill')
120+
await writeSkill(join(codexHome, 'skills', 'custom-codex'), 'custom-codex', 'Custom Codex skill')
121+
122+
const claudeSkills = await listSkills(undefined, { flavor: 'claude' })
123+
const codexSkills = await listSkills(undefined, { flavor: 'codex' })
124+
125+
expect(claudeSkills.map((skill) => skill.name)).toEqual(['custom-claude', 'shared'])
126+
expect(codexSkills.map((skill) => skill.name)).toEqual(['custom-codex', 'shared'])
127+
})
128+
129+
it('includes Codex system skills from a configured CODEX_HOME', async () => {
130+
const codexHome = join(sandboxDir, 'custom-codex')
131+
process.env.CODEX_HOME = codexHome
132+
133+
await writeSkill(join(codexHome, 'skills', 'custom-codex'), 'custom-codex', 'Custom Codex skill')
134+
await writeSkill(join(codexHome, 'skills', '.system', 'custom-system'), 'custom-system', 'Custom Codex system skill')
135+
136+
const skills = await listSkills(undefined, { flavor: 'codex' })
137+
138+
expect(skills.map((skill) => skill.name)).toEqual(['custom-codex', 'custom-system'])
139+
})
140+
141+
it('includes installed marketplace skills for the requested flavor', async () => {
142+
const claudeInstallPath = join(homeDir, '.claude', 'plugins', 'cache', 'owner', 'claude-plugin', '1.0.0')
143+
const codexInstallPath = join(homeDir, '.codex', 'plugins', 'cache', 'owner', 'codex-plugin', '1.0.0')
144+
await writeSkill(join(claudeInstallPath, 'skills', 'claude-market'), 'claude-market', 'Claude marketplace skill')
145+
await writeSkill(join(codexInstallPath, 'skills', 'codex-market'), 'codex-market', 'Codex marketplace skill')
146+
await writeFile(join(homeDir, '.claude', 'plugins', 'installed_plugins.json'), JSON.stringify({
147+
plugins: {
148+
'claude-plugin@owner': [{ installPath: claudeInstallPath, lastUpdated: '2026-01-02T00:00:00.000Z' }]
149+
}
150+
}))
151+
await writeFile(join(homeDir, '.codex', 'plugins', 'installed_plugins.json'), JSON.stringify({
152+
plugins: {
153+
'codex-plugin@owner': [{ installPath: codexInstallPath, lastUpdated: '2026-01-02T00:00:00.000Z' }]
154+
}
155+
}))
156+
157+
const claudeSkills = await listSkills(undefined, { flavor: 'claude' })
158+
const codexSkills = await listSkills(undefined, { flavor: 'codex' })
159+
160+
expect(claudeSkills.map((skill) => skill.name)).toEqual(['claude-market'])
161+
expect(codexSkills.map((skill) => skill.name)).toEqual(['codex-market'])
162+
})
163+
164+
it('uses configured Claude and Codex homes for installed marketplace skills', async () => {
165+
const claudeConfigDir = join(sandboxDir, 'custom-claude')
166+
const codexHome = join(sandboxDir, 'custom-codex')
167+
process.env.CLAUDE_CONFIG_DIR = claudeConfigDir
168+
process.env.CODEX_HOME = codexHome
169+
170+
const claudeInstallPath = join(claudeConfigDir, 'plugins', 'cache', 'owner', 'claude-plugin', '1.0.0')
171+
const codexInstallPath = join(codexHome, 'plugins', 'cache', 'owner', 'codex-plugin', '1.0.0')
172+
await writeSkill(join(claudeInstallPath, 'skills', 'claude-market'), 'claude-market', 'Claude marketplace skill')
173+
await writeSkill(join(codexInstallPath, 'skills', 'codex-market'), 'codex-market', 'Codex marketplace skill')
174+
await writeFile(join(claudeConfigDir, 'plugins', 'installed_plugins.json'), JSON.stringify({
175+
plugins: {
176+
'claude-plugin@owner': [{ installPath: claudeInstallPath, lastUpdated: '2026-01-02T00:00:00.000Z' }]
177+
}
178+
}))
179+
await writeFile(join(codexHome, 'plugins', 'installed_plugins.json'), JSON.stringify({
180+
plugins: {
181+
'codex-plugin@owner': [{ installPath: codexInstallPath, lastUpdated: '2026-01-02T00:00:00.000Z' }]
182+
}
183+
}))
184+
185+
const claudeSkills = await listSkills(undefined, { flavor: 'claude' })
186+
const codexSkills = await listSkills(undefined, { flavor: 'codex' })
187+
188+
expect(claudeSkills.map((skill) => skill.name)).toEqual(['claude-market'])
189+
expect(codexSkills.map((skill) => skill.name)).toEqual(['codex-market'])
190+
})
191+
192+
it('does not list cached marketplace skills that are not installed', async () => {
193+
await writeSkill(join(homeDir, '.codex', 'plugins', 'cache', 'owner', 'stale-plugin', '1.0.0', 'skills', 'stale-market'), 'stale-market', 'Stale marketplace skill')
194+
await mkdir(join(homeDir, '.codex', 'plugins'), { recursive: true })
195+
await writeFile(join(homeDir, '.codex', 'plugins', 'installed_plugins.json'), JSON.stringify({ plugins: {} }))
196+
197+
const skills = await listSkills(undefined, { flavor: 'codex' })
198+
199+
expect(skills.map((skill) => skill.name)).toEqual([])
200+
})
201+
202+
it('uses the newest installed plugin path when multiple installations exist', async () => {
203+
const oldInstallPath = join(homeDir, '.codex', 'plugins', 'cache', 'owner', 'codex-plugin', '1.0.0')
204+
const newInstallPath = join(homeDir, '.codex', 'plugins', 'cache', 'owner', 'codex-plugin', '2.0.0')
205+
await writeSkill(join(oldInstallPath, 'skills', 'plugin-skill'), 'plugin-skill', 'Old marketplace skill')
206+
await writeSkill(join(newInstallPath, 'skills', 'plugin-skill'), 'plugin-skill', 'New marketplace skill')
207+
await writeFile(join(homeDir, '.codex', 'plugins', 'installed_plugins.json'), JSON.stringify({
208+
plugins: {
209+
'codex-plugin@owner': [
210+
{ installPath: oldInstallPath, lastUpdated: '2026-01-01T00:00:00.000Z' },
211+
{ installPath: newInstallPath, lastUpdated: '2026-01-02T00:00:00.000Z' }
212+
]
213+
}
214+
}))
215+
216+
const skills = await listSkills(undefined, { flavor: 'codex' })
217+
218+
expect(skills).toEqual([{ name: 'plugin-skill', description: 'New marketplace skill' }])
219+
})
220+
82221
it('falls back to directory name when frontmatter is missing', async () => {
83222
const skillDir = join(homeDir, '.agents', 'skills', 'no-frontmatter')
84223
await mkdir(skillDir, { recursive: true })
@@ -100,7 +239,7 @@ describe('listSkills', () => {
100239
await writeSkill(join(workingDirectory, '.agents', 'skills', 'local-skill'), 'local-skill', 'Local skill')
101240
await writeSkill(join(sandboxDir, '.agents', 'skills', 'outside-skill'), 'outside-skill', 'Outside repo skill')
102241

103-
const skills = await listSkills(workingDirectory)
242+
const skills = await listSkills(workingDirectory, { flavor: 'claude' })
104243

105244
expect(skills.map((skill) => skill.name)).toEqual(['local-skill', 'package-skill', 'root-skill'])
106245
})
@@ -113,7 +252,7 @@ describe('listSkills', () => {
113252
await writeSkill(join(repoRoot, '.claude', 'skills', 'claude-root'), 'claude-root', 'Claude root skill')
114253
await writeSkill(join(workingDirectory, '.claude', 'skills', 'claude-local'), 'claude-local', 'Claude local skill')
115254

116-
const skills = await listSkills(workingDirectory)
255+
const skills = await listSkills(workingDirectory, { flavor: 'claude' })
117256

118257
expect(skills.map((skill) => skill.name)).toEqual(['claude-local', 'claude-root'])
119258
})
@@ -127,7 +266,7 @@ describe('listSkills', () => {
127266
await writeSkill(join(workingDirectory, '.codex', 'skills', 'codex-local'), 'codex-local', 'Codex local skill')
128267
await writeSkill(join(workingDirectory, '.codex', 'skills', '.system', 'codex-system'), 'codex-system', 'Codex system skill')
129268

130-
const skills = await listSkills(workingDirectory)
269+
const skills = await listSkills(workingDirectory, { flavor: 'codex' })
131270

132271
expect(skills.map((skill) => skill.name)).toEqual(['codex-local', 'codex-root', 'codex-system'])
133272
})

0 commit comments

Comments
 (0)