feat(codex): support slash controls and skill discovery (#545)

* feat(codex): resolve slash controls before sending to Codex

* feat(codex): discover commands and skills

* fix(codex): handle slash commands before attachments

* fix(codex): block remaining unsupported built-ins
This commit is contained in:
CoColate
2026-04-29 09:20:11 +08:00
committed by GitHub
parent 9c117679f0
commit 9d2dec137b
12 changed files with 571 additions and 81 deletions
+16 -3
View File
@@ -69,15 +69,14 @@ describe('listSkills', () => {
expect(skills.find((s) => s.name === 'alpha')?.description).toBe('Alpha from agents')
})
it('lists user skills from ~/.codex/skills', async () => {
it('lists user skills from ~/.codex/skills including Codex bundled system skills', async () => {
await writeSkill(join(homeDir, '.agents', 'skills', 'amis'), 'amis', 'AMIS guide')
await writeSkill(join(homeDir, '.codex', 'skills', 'hello-agents'), 'helloagents', 'Main skill')
// Hidden directories (starting with .) are skipped
await writeSkill(join(homeDir, '.codex', 'skills', '.system', 'skill-creator'), 'skill-creator', 'Create skills')
const skills = await listSkills()
expect(skills.map((skill) => skill.name)).toEqual(['amis', 'helloagents'])
expect(skills.map((skill) => skill.name)).toEqual(['amis', 'helloagents', 'skill-creator'])
})
it('falls back to directory name when frontmatter is missing', async () => {
@@ -119,6 +118,20 @@ describe('listSkills', () => {
expect(skills.map((skill) => skill.name)).toEqual(['claude-local', 'claude-root'])
})
it('loads project skills from .codex/skills directories', async () => {
const repoRoot = join(sandboxDir, 'repo')
const workingDirectory = join(repoRoot, 'apps', 'web')
await mkdir(join(repoRoot, '.git'), { recursive: true })
await writeSkill(join(repoRoot, '.codex', 'skills', 'codex-root'), 'codex-root', 'Codex root skill')
await writeSkill(join(workingDirectory, '.codex', 'skills', 'codex-local'), 'codex-local', 'Codex local skill')
await writeSkill(join(workingDirectory, '.codex', 'skills', '.system', 'codex-system'), 'codex-system', 'Codex system skill')
const skills = await listSkills(workingDirectory)
expect(skills.map((skill) => skill.name)).toEqual(['codex-local', 'codex-root', 'codex-system'])
})
it('prefers .agents project skills over .claude project skills with same name', async () => {
const repoRoot = join(sandboxDir, 'repo')
const workingDirectory = join(repoRoot, 'apps', 'web')
+24 -5
View File
@@ -38,6 +38,7 @@ function getProjectSkillsRoots(directory: string): string[] {
return [
join(directory, '.agents', 'skills'),
join(directory, '.claude', 'skills'),
join(directory, '.codex', 'skills'),
];
}
@@ -105,13 +106,25 @@ function extractSkillSummary(skillDir: string, fileContent: string): SkillSummar
return { name, description };
}
async function listTopLevelSkillDirs(skillsRoot: string): Promise<string[]> {
async function listTopLevelSkillDirs(skillsRoot: string, options: { includeCodexSystem?: boolean } = {}): Promise<string[]> {
try {
const entries = await readdir(skillsRoot, { withFileTypes: true });
const result: string[] = [];
for (const entry of entries) {
if (!entry.isDirectory() || entry.name.startsWith('.')) {
if (!entry.isDirectory()) {
continue;
}
if (entry.name.startsWith('.')) {
if (options.includeCodexSystem && entry.name === '.system') {
const systemEntries = await readdir(join(skillsRoot, entry.name), { withFileTypes: true }).catch(() => []);
for (const systemEntry of systemEntries) {
if (systemEntry.isDirectory() && !systemEntry.name.startsWith('.')) {
result.push(join(skillsRoot, entry.name, systemEntry.name));
}
}
}
continue;
}
@@ -138,12 +151,18 @@ async function readSkillsFromDirs(skillDirs: string[]): Promise<SkillSummary[]>
return skills.filter((skill): skill is SkillSummary => skill !== null);
}
function isCodexSkillsRoot(root: string): boolean {
return root.endsWith(join('.codex', 'skills'));
}
export async function listSkills(workingDirectory?: string): Promise<SkillSummary[]> {
const projectRoots = await listProjectSkillsRoots(workingDirectory);
const userRoots = getUserSkillsRoots();
const adminRoot = getAdminSkillsRoot();
const [projectSkillDirs, userSkillDirs, adminSkillDirs] = await Promise.all([
Promise.all(projectRoots.map(async (root) => await listTopLevelSkillDirs(root))).then((dirs) => dirs.flat()),
Promise.all(getUserSkillsRoots().map(async (root) => await listTopLevelSkillDirs(root))).then((dirs) => dirs.flat()),
listTopLevelSkillDirs(getAdminSkillsRoot()),
Promise.all(projectRoots.map(async (root) => await listTopLevelSkillDirs(root, { includeCodexSystem: isCodexSkillsRoot(root) }))).then((dirs) => dirs.flat()),
Promise.all(userRoots.map(async (root) => await listTopLevelSkillDirs(root, { includeCodexSystem: isCodexSkillsRoot(root) }))).then((dirs) => dirs.flat()),
listTopLevelSkillDirs(adminRoot, { includeCodexSystem: true }),
]);
const [projectSkills, userSkills, adminSkills] = await Promise.all([
@@ -6,19 +6,25 @@ import { listSlashCommands } from './slashCommands'
describe('listSlashCommands', () => {
const originalClaudeConfigDir = process.env.CLAUDE_CONFIG_DIR
const originalCodexHome = process.env.CODEX_HOME
let sandboxDir: string
let claudeConfigDir: string
let codexHome: string
let projectDir: string
beforeEach(async () => {
sandboxDir = await mkdtemp(join(tmpdir(), 'hapi-slash-commands-'))
claudeConfigDir = join(sandboxDir, 'global-claude')
codexHome = join(sandboxDir, 'global-codex')
projectDir = join(sandboxDir, 'project')
process.env.CLAUDE_CONFIG_DIR = claudeConfigDir
process.env.CODEX_HOME = codexHome
await mkdir(join(claudeConfigDir, 'commands'), { recursive: true })
await mkdir(join(codexHome, 'prompts'), { recursive: true })
await mkdir(join(projectDir, '.claude', 'commands'), { recursive: true })
await mkdir(join(projectDir, '.codex', 'prompts'), { recursive: true })
})
afterEach(async () => {
@@ -27,6 +33,11 @@ describe('listSlashCommands', () => {
} else {
process.env.CLAUDE_CONFIG_DIR = originalClaudeConfigDir
}
if (originalCodexHome === undefined) {
delete process.env.CODEX_HOME
} else {
process.env.CODEX_HOME = originalCodexHome
}
await rm(sandboxDir, { recursive: true, force: true })
})
@@ -98,4 +109,67 @@ describe('listSlashCommands', () => {
await expect(listSlashCommands('claude', nonExistentProjectDir)).resolves.toBeDefined()
})
it('exposes HAPI-supported Codex built-ins', async () => {
const commands = await listSlashCommands('codex', projectDir)
expect(commands.map((command) => command.name)).toEqual(expect.arrayContaining([
'plan',
'status',
'model',
'reasoning',
'permissions',
]))
})
it('loads Codex global and project prompts', async () => {
await writeFile(
join(codexHome, 'prompts', 'global-prompt.md'),
['---', 'description: Global Codex prompt', '---', '', 'Global Codex body'].join('\n')
)
await writeFile(
join(projectDir, '.codex', 'prompts', 'project-prompt.md'),
['---', 'description: Project Codex prompt', '---', '', 'Project Codex body'].join('\n')
)
const commands = await listSlashCommands('codex', projectDir)
expect(commands.find(cmd => cmd.name === 'global-prompt')).toMatchObject({
source: 'user',
description: 'Global Codex prompt',
content: 'Global Codex body',
})
expect(commands.find(cmd => cmd.name === 'project-prompt')).toMatchObject({
source: 'project',
description: 'Project Codex prompt',
content: 'Project Codex body',
})
})
it('loads Codex project prompts from cwd up to repo root with nearest override', async () => {
const repoRoot = join(sandboxDir, 'repo')
const workingDirectory = join(repoRoot, 'apps', 'web')
await mkdir(join(repoRoot, '.git'), { recursive: true })
await mkdir(join(repoRoot, '.codex', 'prompts'), { recursive: true })
await mkdir(join(workingDirectory, '.codex', 'prompts'), { recursive: true })
await writeFile(
join(repoRoot, '.codex', 'prompts', 'shared.md'),
['---', 'description: Root prompt', '---', '', 'Root body'].join('\n')
)
await writeFile(
join(workingDirectory, '.codex', 'prompts', 'shared.md'),
['---', 'description: Local prompt', '---', '', 'Local body'].join('\n')
)
const commands = await listSlashCommands('codex', workingDirectory)
const sharedCommands = commands.filter(cmd => cmd.name === 'shared')
expect(sharedCommands).toHaveLength(1)
expect(sharedCommands[0]).toMatchObject({
source: 'project',
description: 'Local prompt',
content: 'Local body',
})
})
})
+54 -13
View File
@@ -1,5 +1,5 @@
import { readdir, readFile } from 'fs/promises';
import { join } from 'path';
import { access, readdir, readFile } from 'fs/promises';
import { dirname, join, resolve } from 'path';
import { homedir } from 'os';
import { parse as parseYaml } from 'yaml';
@@ -32,7 +32,18 @@ const BUILTIN_COMMANDS: Record<string, SlashCommand[]> = {
{ name: 'cost', description: 'Show session cost', source: 'builtin' },
{ name: 'plan', description: 'Toggle plan mode', source: 'builtin' },
],
codex: [],
codex: [
{ name: 'help', description: 'Show supported HAPI Codex slash commands', source: 'builtin' },
{ name: 'plan', description: 'Enable plan mode; use /plan off to return to default', source: 'builtin' },
{ name: 'default', description: 'Return Codex collaboration mode to default', source: 'builtin' },
{ name: 'execute', description: 'Return Codex collaboration mode to default', source: 'builtin' },
{ name: 'status', description: 'Show current Codex session config', source: 'builtin' },
{ name: 'model', description: 'Show or set Codex model, e.g. /model gpt-5.5', source: 'builtin' },
{ name: 'reasoning', description: 'Show or set reasoning effort', source: 'builtin' },
{ name: 'effort', description: 'Alias for /reasoning', source: 'builtin' },
{ name: 'permissions', description: 'Show or set permission mode', source: 'builtin' },
{ name: 'permission', description: 'Alias for /permissions', source: 'builtin' },
],
gemini: [
{ name: 'about', description: 'About Gemini', source: 'builtin' },
{ name: 'clear', description: 'Clear conversation', source: 'builtin' },
@@ -115,6 +126,43 @@ function getProjectCommandsDir(agent: string, projectDir: string): string | null
}
}
async function pathExists(path: string): Promise<boolean> {
try {
await access(path);
return true;
} catch {
return false;
}
}
async function listProjectCommandDirs(agent: string, projectDir?: string): Promise<string[]> {
if (!projectDir) {
return [];
}
const resolvedProjectDir = resolve(projectDir);
const directories = [resolvedProjectDir];
let currentDirectory = resolvedProjectDir;
while (true) {
if (await pathExists(join(currentDirectory, '.git'))) {
return [...directories]
.reverse()
.map((directory) => getProjectCommandsDir(agent, directory))
.filter((directory): directory is string => directory !== null);
}
const parentDirectory = dirname(currentDirectory);
if (parentDirectory === currentDirectory) {
const dir = getProjectCommandsDir(agent, resolvedProjectDir);
return dir ? [dir] : [];
}
currentDirectory = parentDirectory;
directories.push(currentDirectory);
}
}
/**
* Scan a directory for commands (*.md files).
* Returns commands with parsed frontmatter.
@@ -199,16 +247,9 @@ async function scanUserCommands(agent: string): Promise<SlashCommand[]> {
* Scan project-defined commands from <projectDir>/.claude/commands/ or equivalent.
*/
async function scanProjectCommands(agent: string, projectDir?: string): Promise<SlashCommand[]> {
if (!projectDir) {
return [];
}
const dir = getProjectCommandsDir(agent, projectDir);
if (!dir) {
return [];
}
return scanCommandsDir(dir, 'project');
const dirs = await listProjectCommandDirs(agent, projectDir);
const commands = await Promise.all(dirs.map(async (dir) => await scanCommandsDir(dir, 'project')));
return commands.flat();
}
/**