mirror of
https://github.com/CharlesWiltgen/Axiom.git
synced 2026-09-20 19:58:20 +08:00
7482a0273d
Three suites (axiom-tools, axiom-shipping, axiom-apple-docs) were excluded from
the Codex build. That was not a decision — the original list (2b5d6adb) was a
blanket "Router skills — Codex has native progressive disclosure, so these are
unnecessary". The premise was abandoned and the list curated, but these three
survived: two still annotated "Codex fit not yet assessed", and axiom-tools
carried a reason written when it was named axiom-using-axiom and held ONLY
discipline text. It then absorbed six sub-skills, five of them harness-neutral
tool references, all inheriting an exclusion that predated them.
Measured cost: 41 dangling "see X" pointers plus ~94 name citations across ~40
emitted Codex skills, aimed at files the build never wrote.
- CODEX_EXCLUDED_SUITES is now empty; per-FILE exclusion replaces it, holding one
entry — getting-started.md, which drives AskUserQuestion and cites 15 /axiom:
commands, and has zero inbound references.
- dropExcludedSubSkillRows strips a router's table row for a withheld sub-skill,
so per-file exclusion cannot recreate the defect it exists to avoid.
- rewriteInlineAuditorBlockForCodex translates auditor file paths to Codex skill
names; that block previously told Codex users to read files this build skips.
- rewriteAgentInvokesForCodex fixes the layer under that: routers said "Launch
`build-fixer` agent or `/axiom:fix-build`" while `axiom-fix-build` — the thing
that exists there — appeared zero times. Neither agents nor /axiom: commands
exist on Codex, and the mapping is not derivable (spm-conflict-resolver →
axiom-resolve-spm). Applied to routers, agent-skills, and sub-skills, since a
verbatim copy reintroduces it. Two commands have no Codex equivalent at all
(console, compare-traces) and are labelled Claude Code-only rather than
redirected somewhere that does a different job.
- agentToSkillName moved into the shared module so the pre-deploy gate derives
the agent-skill set instead of guessing from names — a name heuristic
misclassifies both ways (axiom-health and axiom-testing are routers that read
verb-first; axiom-swift-simplifier is an agent-skill that does not).
- The count test now derives the agent total from the agents directory; the
hardcoded 41/68 had drifted from a real 42/69 and passed anyway by being
self-consistent.
537 lines
24 KiB
JavaScript
537 lines
24 KiB
JavaScript
#!/usr/bin/env node
|
|
import fs from 'node:fs';
|
|
import path from 'node:path';
|
|
import { fileURLToPath } from 'node:url';
|
|
import matter from 'gray-matter';
|
|
import {
|
|
CODEX_EXCLUDED_SUITES,
|
|
isEmittableAgent,
|
|
isExcludedSubSkill,
|
|
dropExcludedSubSkillRows,
|
|
agentToSkillName,
|
|
} from './codex-exclude.js';
|
|
import { translateHooksToCodex, shouldCopyHookScript } from './codex-hooks.js';
|
|
import { isGeneratedSubSkill, parseAgentTools } from './inline-auditors.ts';
|
|
|
|
const __filename = fileURLToPath(import.meta.url);
|
|
const root = path.dirname(path.dirname(__filename));
|
|
|
|
const SOURCE_SKILLS = path.join(root, '.claude-plugin/plugins/axiom/skills');
|
|
const OUTPUT_DIR = path.join(root, 'axiom-codex');
|
|
const OUTPUT_SKILLS = path.join(OUTPUT_DIR, 'skills');
|
|
const OUTPUT_MANIFEST = path.join(OUTPUT_DIR, '.codex-plugin');
|
|
|
|
// Suites withheld from the Codex variant — the curated list + rationale live in
|
|
// scripts/codex-exclude.js, the single source of truth shared with the pre-deploy
|
|
// fidelity gate so the two can't drift (axiom-altb).
|
|
const EXCLUDE_SKILLS = new Set(CODEX_EXCLUDED_SUITES);
|
|
|
|
// Read version from Claude Code manifest
|
|
const ccManifest = JSON.parse(
|
|
fs.readFileSync(path.join(root, '.claude-plugin/plugins/axiom/claude-code.json'), 'utf8')
|
|
);
|
|
const version = ccManifest.version;
|
|
|
|
// Clean and recreate output
|
|
if (fs.existsSync(OUTPUT_DIR)) {
|
|
fs.rmSync(OUTPUT_DIR, { recursive: true });
|
|
}
|
|
fs.mkdirSync(OUTPUT_SKILLS, { recursive: true });
|
|
fs.mkdirSync(OUTPUT_MANIFEST, { recursive: true });
|
|
|
|
// Parse SKILL.md frontmatter via gray-matter (shared with axiom-mcp)
|
|
function parseFrontmatter(content: string): Record<string, string> {
|
|
const { data } = matter(content);
|
|
return data as Record<string, string>;
|
|
}
|
|
|
|
// Known casing for iOS/Apple terms
|
|
const CASE_MAP: Record<string, string> = {
|
|
swiftui: 'SwiftUI', swiftdata: 'SwiftData', coredata: 'CoreData',
|
|
cloudkit: 'CloudKit', storekit: 'StoreKit', spritekit: 'SpriteKit',
|
|
scenekit: 'SceneKit', realitykit: 'RealityKit', uikit: 'UIKit',
|
|
appkit: 'AppKit', mapkit: 'MapKit', eventkit: 'EventKit',
|
|
textkit: 'TextKit', metalkit: 'MetalKit', cryptokit: 'CryptoKit',
|
|
lldb: 'LLDB', grdb: 'GRDB', ios: 'iOS', tvos: 'tvOS',
|
|
iap: 'IAP', icloud: 'iCloud', hig: 'HIG', ux: 'UX',
|
|
sf: 'SF', mcp: 'MCP', asc: 'ASC', tdd: 'TDD',
|
|
ref: 'Reference', diag: 'Diagnostics', objc: 'Obj-C',
|
|
avfoundation: 'AVFoundation', xctest: 'XCTest', xctrace: 'xctrace',
|
|
xclog: 'xclog', sqlitedata: 'SQLiteData', metrickit: 'MetricKit',
|
|
alarmkit: 'AlarmKit', shazamkit: 'ShazamKit', musickit: 'MusicKit',
|
|
carplay: 'CarPlay', haptics: 'Haptics',
|
|
};
|
|
|
|
// Derive display name: "axiom-swiftui-performance" → "SwiftUI Performance"
|
|
function toDisplayName(skillName: string): string {
|
|
return skillName
|
|
.replace(/^axiom-/, '')
|
|
.split('-')
|
|
.map(w => CASE_MAP[w] || w.charAt(0).toUpperCase() + w.slice(1))
|
|
.join(' ');
|
|
}
|
|
|
|
// Derive short_description from full description
|
|
function toShortDescription(description: string): string {
|
|
// Strip "Use when" / "Use for" prefix
|
|
let short = description.replace(/^Use (?:when|for)\s*/i, '');
|
|
// Take up to first period, em dash, or " - " delimiter — but only if we'd keep 20+ chars
|
|
const end = short.search(/\.\s|—|\s-\s/);
|
|
if (end >= 20) short = short.slice(0, end);
|
|
if (short.length > 120) short = short.slice(0, 117) + '...';
|
|
// Escape for YAML double-quoted string (backslashes first, then quotes) and trim
|
|
short = short.replace(/\\/g, '\\\\').replace(/"/g, '\\"').trim();
|
|
return short.charAt(0).toUpperCase() + short.slice(1);
|
|
}
|
|
|
|
// Recursively find all SKILL.md files (supports nested skills like axiom-ios-ml/coreml/)
|
|
interface SkillEntry {
|
|
name: string; // from frontmatter or directory name
|
|
sourcePath: string; // full path to SKILL.md
|
|
content: string; // file content
|
|
frontmatter: Record<string, string>;
|
|
}
|
|
|
|
function findSkillEntries(dir: string): SkillEntry[] {
|
|
const results: SkillEntry[] = [];
|
|
const entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
for (const entry of entries) {
|
|
if (!entry.isDirectory()) continue;
|
|
const entryPath = path.join(dir, entry.name);
|
|
const skillFile = path.join(entryPath, 'SKILL.md');
|
|
if (fs.existsSync(skillFile)) {
|
|
const content = fs.readFileSync(skillFile, 'utf8');
|
|
const fm = parseFrontmatter(content);
|
|
const name = fm.name || entry.name;
|
|
if (!EXCLUDE_SKILLS.has(name)) {
|
|
results.push({ name, sourcePath: skillFile, content, frontmatter: fm });
|
|
}
|
|
}
|
|
// Recurse into subdirectories
|
|
results.push(...findSkillEntries(entryPath));
|
|
}
|
|
return results;
|
|
}
|
|
|
|
// Verbs agentToSkillName puts first, so a skill name reads as an instruction.
|
|
const SKILL_VERBS = new Set([
|
|
'audit', 'analyze', 'scan', 'fix', 'test', 'run', 'debug',
|
|
'profile', 'validate', 'resolve', 'implement', 'optimize', 'modernize',
|
|
]);
|
|
|
|
// The task half of `default_prompt`, derived from the skill's own verb-first NAME
|
|
// rather than its description. Descriptions are trigger-shaped ("The user mentions
|
|
// memory leak prevention…") and do not read as an instruction; the name already
|
|
// encodes the action, so `axiom-audit-memory` → "audit memory in this project".
|
|
// Names that aren't verb-first fall back to a phrasing that stays grammatical.
|
|
function toDefaultPromptTask(skillName: string): string {
|
|
const words = skillName.replace(/^axiom-/, '').split('-');
|
|
if (words.length > 1 && SKILL_VERBS.has(words[0])) {
|
|
return `${words.join(' ')} in this project.`;
|
|
}
|
|
if (words.length === 1 && SKILL_VERBS.has(words[0])) {
|
|
return `${words[0]} this project.`;
|
|
}
|
|
return `run the ${toDisplayName(skillName)} skill on this project.`;
|
|
}
|
|
|
|
// The AXIOM_AUDITOR_INLINE block tells non-Claude-Code harnesses to READ an
|
|
// auditor's sub-skill file. That instruction is false on Codex: the refsDir loop
|
|
// below deliberately skips those files, because the agent-conversion pass emits
|
|
// every auditor as a first-class skill instead. Shipped verbatim, the block aims
|
|
// ~14 pointers at files this build never writes — the same dangling-pointer defect
|
|
// that suite-level exclusion used to cause (Axiom-ky1). Translate names, don't drop
|
|
// the block: the routing advice is right, only the addresses are wrong.
|
|
function rewriteInlineAuditorBlockForCodex(content: string): string {
|
|
const BLOCK = /<!-- AXIOM_AUDITOR_INLINE_BEGIN[\s\S]*?AXIOM_AUDITOR_INLINE_END -->/;
|
|
return content.replace(BLOCK, (block) => {
|
|
const names = new Set<string>();
|
|
for (const m of block.matchAll(/`(?:axiom-[a-z0-9-]+\/)?skills\/([a-z0-9-]+)\.md`/g)) {
|
|
names.add(agentToSkillName(m[1]));
|
|
}
|
|
if (names.size === 0) return block;
|
|
const list = [...names].sort().map((n) => `\`${n}\``).join(', ');
|
|
return [
|
|
'<!-- AXIOM_AUDITOR_INLINE_BEGIN — rewritten for Codex by scripts/build-codex.ts; do not hand-edit -->',
|
|
'> **Auditors are skills here.** Where this router says "Launch `some-auditor` agent", invoke the',
|
|
'> matching Codex skill instead — same procedure, no Claude Code agent required.',
|
|
'>',
|
|
`> Available: ${list}.`,
|
|
'>',
|
|
'> The ones that shell out — builds, tests, simulators, crash symbolication — need shell access to run.',
|
|
'<!-- AXIOM_AUDITOR_INLINE_END -->',
|
|
].join('\n');
|
|
});
|
|
}
|
|
|
|
// `/axiom:<cmd>` → Codex skill, for the cases the mechanical `axiom-<cmd>` rule
|
|
// misses because the command and its agent were named independently. `null` means
|
|
// no Codex skill provides it, so the text is labelled Claude-Code-only rather than
|
|
// redirected to something that does a different job.
|
|
const CODEX_COMMAND_MAP: Record<string, string | null> = {
|
|
profile: 'axiom-profile-performance',
|
|
triage: 'axiom-analyze-triage',
|
|
screenshot: 'axiom-validate-screenshots',
|
|
'audit screenshots': 'axiom-validate-screenshots',
|
|
'audit swift-simplify': 'axiom-swift-simplifier',
|
|
'audit swiftui-performance': 'axiom-analyze-swiftui-performance',
|
|
'audit swift-performance': 'axiom-analyze-swift-performance',
|
|
'audit test-failures': 'axiom-analyze-test-failures',
|
|
'audit security': 'axiom-scan-security-privacy',
|
|
'audit modernization': 'axiom-modernize',
|
|
'fix-build': 'axiom-fix-build',
|
|
'run-tests': 'axiom-run-tests',
|
|
'test-simulator': 'axiom-test-simulator',
|
|
'resolve-deps': 'axiom-resolve-spm',
|
|
modernize: 'axiom-modernize',
|
|
// No Codex equivalent: xclog console capture and trace comparison are
|
|
// Claude-Code-only surfaces today.
|
|
console: null,
|
|
'compare-traces': null,
|
|
};
|
|
|
|
// Routers tell the reader to "Launch `build-fixer` agent or `/axiom:fix-build`".
|
|
// Neither exists on Codex: there are no agents, and `/axiom:*` commands aren't
|
|
// emitted. What DOES exist is a skill named `axiom-fix-build` — and the mapping is
|
|
// not derivable (spm-conflict-resolver → axiom-resolve-spm), so a Codex model cannot
|
|
// recover it from the bare agent name.
|
|
//
|
|
// Rewriting the AXIOM_AUDITOR_INLINE block alone was not enough: that block only
|
|
// names agents with an inlined sub-skill file, which is 2 of the 7 axiom-build
|
|
// launches. The other five (fix-build, resolve-spm, optimize-build, analyze-crash,
|
|
// test-runner) shell out, have no inline copy, and were left pointing at nothing.
|
|
// This is the Axiom-ky1 defect one indirection deeper — same fix, applied to the
|
|
// primary invoke instruction rather than the cross-reference.
|
|
function rewriteAgentInvokesForCodex(content: string): string {
|
|
return content
|
|
// "Launch `X` agent or `/axiom:y`" → "Invoke the `axiom-…` skill"
|
|
// Only rewrite names that are REAL agents. The inline block's own illustrative
|
|
// text says "Launch `some-auditor` agent"; an unguarded rewrite turned that
|
|
// placeholder into `axiom-audit-some`, inventing a skill that does not exist.
|
|
.replace(
|
|
/Launch `([a-z0-9-]+)` agent(?: or `\/axiom:[^`]+`)?/g,
|
|
(whole: string, agent: string) =>
|
|
sourceAgentNames.has(agent) ? `\`${agentToSkillName(agent)}\`` : whole,
|
|
)
|
|
// Codex ships no `/axiom:*` commands at all. Most map mechanically
|
|
// (`/axiom:audit energy` → `axiom-audit-energy`); a few need the explicit map
|
|
// below because the command and the agent were named independently.
|
|
.replace(
|
|
/`\/axiom:(audit\s+)?([a-z0-9-]+)(\s+[a-z0-9-]+)?`/g,
|
|
(whole: string, audit: string | undefined, name: string, arg: string | undefined) => {
|
|
const key = audit ? `audit ${name}` : name;
|
|
const mapped = CODEX_COMMAND_MAP[key] ?? CODEX_COMMAND_MAP[name];
|
|
if (mapped === null) {
|
|
// Genuinely Claude-Code-only: no Codex skill provides this. Say so rather
|
|
// than redirect to something that does a different job.
|
|
return `${whole} (Claude Code only)`;
|
|
}
|
|
if (mapped) return `\`${mapped}\``;
|
|
const candidate = audit ? `axiom-audit-${name}` : `axiom-${name}`;
|
|
if (codexSkillNames.has(candidate)) {
|
|
// `/axiom:triage sentry` carries an argument the skill still accepts.
|
|
return arg ? `\`${candidate}\`${arg}` : `\`${candidate}\``;
|
|
}
|
|
return whole;
|
|
},
|
|
);
|
|
}
|
|
|
|
// Copy skills and generate openai.yaml
|
|
const skillEntries = findSkillEntries(SOURCE_SKILLS);
|
|
|
|
// Every skill name the Codex build will emit, so the rewrite above only redirects to
|
|
// targets that actually ship. Derived from the source agents through the same naming
|
|
// function the builder and the pre-deploy gate use.
|
|
const sourceAgentNames = new Set<string>(
|
|
fs
|
|
.readdirSync(path.join(root, ".claude-plugin/plugins/axiom/agents"))
|
|
.filter((f) => f.endsWith(".md"))
|
|
.map((f) => path.basename(f, ".md")),
|
|
);
|
|
|
|
const codexSkillNames = new Set<string>(
|
|
fs
|
|
.readdirSync(path.join(root, '.claude-plugin/plugins/axiom/agents'))
|
|
.filter((f) => f.endsWith('.md'))
|
|
.map((f) => agentToSkillName(path.basename(f, '.md'))),
|
|
);
|
|
|
|
let copied = 0;
|
|
for (const skill of skillEntries) {
|
|
const destDir = path.join(OUTPUT_SKILLS, skill.name);
|
|
fs.mkdirSync(destDir, { recursive: true });
|
|
// Router content is written, not copied: a per-file sub-skill exclusion must also
|
|
// remove the router's routing-table row for it, or the Codex build ships a pointer
|
|
// to a file it never wrote.
|
|
fs.writeFileSync(
|
|
path.join(destDir, 'SKILL.md'),
|
|
rewriteAgentInvokesForCodex(
|
|
rewriteInlineAuditorBlockForCodex(dropExcludedSubSkillRows(skill.content, skill.name))
|
|
)
|
|
);
|
|
|
|
// Copy skills/ directory if it exists (skill suites).
|
|
//
|
|
// Router-inlined auditors are SKIPPED here. They exist so harnesses that
|
|
// install via the Agent-Skills spec can reach an auditor's procedure at all;
|
|
// Codex already gets every auditor as a first-class `axiom-audit-*` skill
|
|
// from the agent-conversion pass below, with proper frontmatter and
|
|
// disable-model-invocation. Copying them too would ship each procedure twice
|
|
// in one plugin (~459 KB) under two different names.
|
|
const refsDir = path.join(path.dirname(skill.sourcePath), 'skills');
|
|
if (fs.existsSync(refsDir)) {
|
|
const destRefs = path.join(destDir, 'skills');
|
|
fs.mkdirSync(destRefs, { recursive: true });
|
|
for (const ref of fs.readdirSync(refsDir)) {
|
|
const refPath = path.join(refsDir, ref);
|
|
if (isGeneratedSubSkill(fs.readFileSync(refPath, 'utf8'))) continue;
|
|
if (isExcludedSubSkill(skill.name, ref)) continue;
|
|
// Sub-skills cite agents and /axiom: commands too, so they need the same
|
|
// translation as routers — a verbatim copy reintroduces the dangling invokes.
|
|
fs.writeFileSync(
|
|
path.join(destRefs, ref),
|
|
rewriteAgentInvokesForCodex(fs.readFileSync(refPath, "utf8"))
|
|
);
|
|
}
|
|
}
|
|
|
|
// Generate agents/openai.yaml from frontmatter
|
|
if (skill.frontmatter.name && skill.frontmatter.description) {
|
|
const agentsDir = path.join(destDir, 'agents');
|
|
fs.mkdirSync(agentsDir, { recursive: true });
|
|
const yaml = [
|
|
'interface:',
|
|
` display_name: "${toDisplayName(skill.name)}"`,
|
|
` short_description: "${toShortDescription(skill.frontmatter.description)}"`,
|
|
'',
|
|
].join('\n');
|
|
fs.writeFileSync(path.join(agentsDir, 'openai.yaml'), yaml);
|
|
} else {
|
|
console.warn(` warn: skipped openai.yaml for ${skill.name} (missing name or description in frontmatter)`);
|
|
}
|
|
|
|
copied++;
|
|
}
|
|
|
|
// Generate plugin.json
|
|
const pluginManifest = {
|
|
name: 'axiom',
|
|
version,
|
|
description: 'Battle-tested skills for modern iOS development — SwiftUI, concurrency, data, performance, networking, accessibility, and more.',
|
|
author: {
|
|
name: 'Charles Wiltgen',
|
|
url: 'https://charleswiltgen.github.io/Axiom/',
|
|
},
|
|
homepage: 'https://charleswiltgen.github.io/Axiom/',
|
|
repository: 'https://github.com/CharlesWiltgen/Axiom',
|
|
license: 'MIT',
|
|
keywords: ['ios', 'swift', 'swiftui', 'xcode', 'apple', 'mobile', 'development'],
|
|
skills: './skills/',
|
|
// Relative path from the plugin root (axiom-codex/) to the MCP
|
|
// server list. Codex's plugin loader resolves this path against the
|
|
// plugin root and reads PluginMcpFile from it. The file itself is
|
|
// written alongside plugin.json below. Schema source:
|
|
// https://github.com/openai/codex/blob/main/codex-rs/core-plugins/src/loader.rs
|
|
mcpServers: './.mcp.json',
|
|
interface: {
|
|
displayName: 'Axiom',
|
|
shortDescription: 'Battle-tested iOS development skills',
|
|
longDescription: 'Axiom gives AI coding assistants deep iOS development expertise — preventing data loss from bad migrations, catching memory leaks, diagnosing build failures, and guiding Swift concurrency, SwiftUI, networking, accessibility, and more.',
|
|
developerName: 'Charles Wiltgen',
|
|
category: 'Development',
|
|
capabilities: ['Read'],
|
|
websiteURL: 'https://charleswiltgen.github.io/Axiom/',
|
|
privacyPolicyURL: 'https://charleswiltgen.github.io/Axiom/privacy',
|
|
termsOfServiceURL: 'https://charleswiltgen.github.io/Axiom/terms',
|
|
brandColor: '#3451b2',
|
|
defaultPrompt: [
|
|
'Check my SwiftUI code for performance issues',
|
|
'Help me fix this build failure',
|
|
'How do I safely add a database column?',
|
|
],
|
|
},
|
|
};
|
|
|
|
fs.writeFileSync(
|
|
path.join(OUTPUT_MANIFEST, 'plugin.json'),
|
|
JSON.stringify(pluginManifest, null, 2) + '\n'
|
|
);
|
|
|
|
// Generate .mcp.json — the plugin-local MCP server list Codex reads when
|
|
// a user installs the plugin. Keyed by logical server name; each value
|
|
// maps onto Codex's McpServerConfig (same shape as ~/.codex/config.toml's
|
|
// [mcp_servers.NAME] section). Lives at the plugin root so the
|
|
// pluginManifest.mcpServers path ('./.mcp.json') resolves correctly.
|
|
const mcpFile = {
|
|
mcpServers: {
|
|
axiom: {
|
|
command: 'npx',
|
|
args: ['-y', 'axiom-mcp'],
|
|
},
|
|
},
|
|
};
|
|
fs.writeFileSync(
|
|
path.join(OUTPUT_DIR, '.mcp.json'),
|
|
JSON.stringify(mcpFile, null, 2) + '\n'
|
|
);
|
|
|
|
// --- Generate hooks/ — port the Claude Code lifecycle hooks to Codex (bd axiom-25ll) ---
|
|
// Codex plugins auto-discover hooks at the plugin-root hooks/hooks.json. translateHooksToCodex
|
|
// rewrites ${CLAUDE_PLUGIN_ROOT} -> ${PLUGIN_ROOT}, drops groups Codex can't fire ("Read"),
|
|
// and strips matchers Codex rejects (UserPromptSubmit/Stop). The script copy is a DENYLIST
|
|
// (shouldCopyHookScript) so transitive deps (session-start.sh -> session-start.py ->
|
|
// project_detect.py, none of which appear in hooks.json) are copied automatically.
|
|
const SOURCE_HOOKS = path.join(root, '.claude-plugin/plugins/axiom/hooks');
|
|
const OUTPUT_HOOKS = path.join(OUTPUT_DIR, 'hooks');
|
|
fs.mkdirSync(OUTPUT_HOOKS, { recursive: true });
|
|
|
|
const ccHooks = JSON.parse(fs.readFileSync(path.join(SOURCE_HOOKS, 'hooks.json'), 'utf8'));
|
|
fs.writeFileSync(
|
|
path.join(OUTPUT_HOOKS, 'hooks.json'),
|
|
JSON.stringify(translateHooksToCodex(ccHooks), null, 2) + '\n'
|
|
);
|
|
|
|
let hookScriptsCopied = 0;
|
|
for (const file of fs.readdirSync(SOURCE_HOOKS)) {
|
|
if (!shouldCopyHookScript(file)) continue;
|
|
const dest = path.join(OUTPUT_HOOKS, file);
|
|
fs.copyFileSync(path.join(SOURCE_HOOKS, file), dest);
|
|
// session-start.sh is invoked directly under Codex's `sh -lc`, so it needs the
|
|
// execute bit (copyFileSync does not guarantee mode preservation). The .py hooks are
|
|
// invoked via `python3 "..."`, so they don't.
|
|
if (file.endsWith('.sh')) fs.chmodSync(dest, 0o755);
|
|
hookScriptsCopied++;
|
|
}
|
|
|
|
// --- Convert agents to on-demand Codex skills ---
|
|
const SOURCE_AGENTS = path.join(root, '.claude-plugin/plugins/axiom/agents');
|
|
|
|
// Agents that declare Bash need interactive capabilities, and their Codex skill
|
|
// says so. Derived from the agent's own tools: declaration rather than a
|
|
// hand-maintained list — the previous hardcoded set listed 8 agents while 11
|
|
// actually declare Bash (iap-implementation, spm-conflict-resolver and
|
|
// triage-analyzer were missing their note), and any new Bash agent would have
|
|
// silently missed it too.
|
|
function agentNeedsBash(content: string): boolean {
|
|
const parsed = parseAgentTools(content);
|
|
return parsed.kind === 'ok' && parsed.tools.includes('Bash');
|
|
}
|
|
|
|
// Extract first sentence of agent description for skill description
|
|
function agentDescriptionToSkillDescription(desc: string): string {
|
|
// Strip example blocks and "Explicit command" lines
|
|
let clean = desc
|
|
.replace(/<example>[\s\S]*?<\/example>/g, '')
|
|
.replace(/Explicit command:.*$/gm, '')
|
|
.trim();
|
|
// Take the first sentence. The terminating period must be followed by whitespace
|
|
// or end-of-string: Axiom descriptions are full of file extensions (`.ips`,
|
|
// `.crash`, `.xccrashpoint`), and a plain /^[^.]+\./ ended the sentence at the
|
|
// first of those. crash-analyzer's description became "Use when the user has a
|
|
// crash log (." — the string Codex actually routes on (Axiom-izi).
|
|
const firstSentence = clean.match(/^[\s\S]*?\.(?=\s|$)/)?.[0] || clean.split('\n')[0];
|
|
let result = firstSentence.trim();
|
|
// Convert from "Use this agent when..." to "Use when..."
|
|
result = result.replace(/^Use this agent when/i, 'Use when');
|
|
if (result.length > 250) result = result.slice(0, 247) + '...';
|
|
return result;
|
|
}
|
|
|
|
const agentFiles = fs.readdirSync(SOURCE_AGENTS)
|
|
.filter(f => f.endsWith('.md'));
|
|
|
|
let agentsCopied = 0;
|
|
for (const file of agentFiles) {
|
|
const agentName = file.replace(/\.md$/, '');
|
|
const skillName = agentToSkillName(agentName);
|
|
const content = fs.readFileSync(path.join(SOURCE_AGENTS, file), 'utf8');
|
|
const { data: fm, content: body } = matter(content);
|
|
|
|
if (!isEmittableAgent(fm)) {
|
|
console.warn(` warn: skipped agent ${agentName} (missing name or description)`);
|
|
continue;
|
|
}
|
|
|
|
const description = agentDescriptionToSkillDescription(
|
|
typeof fm.description === 'string' ? fm.description : ''
|
|
);
|
|
|
|
const needsBash = agentNeedsBash(content);
|
|
const bashNote = needsBash
|
|
? '\n\n> **Note:** This audit may use Bash commands to run builds, tests, or CLI tools.\n'
|
|
: '';
|
|
|
|
// NO `disable-model-invocation` here. Codex's own plugin validator rejects it:
|
|
//
|
|
// if disable_model_invocation not in (None, False):
|
|
// errors.append("... frontmatter field `disable-model-invocation` must be false")
|
|
// — codex-rs/skills/src/assets/samples/plugin-creator/scripts/validate_plugin.py
|
|
//
|
|
// Emitting it failed validation on all 42 agent-skills (verified against Codex
|
|
// 0.147.0 on 2026-08-15: exit 1, 42 errors, that rule and nothing else). Axiom-izi
|
|
// advised keeping the key on the theory that Cursor honors the same spelling from
|
|
// .agents/skills/ — but Axiom ships no .agents/skills/, only the Codex marketplace
|
|
// manifest, so nothing was gained and validation was lost.
|
|
//
|
|
// Explicit-invoke-only on Codex is carried by `policy.allow_implicit_invocation:
|
|
// false` in agents/openai.yaml below, which is what the runtime actually reads.
|
|
// The Claude Code plugin keeps the frontmatter key, where it IS the supported
|
|
// mechanism — this omission is Codex-only.
|
|
const skillContent = [
|
|
'---',
|
|
`name: ${skillName}`,
|
|
`description: ${description}`,
|
|
'license: MIT',
|
|
'---',
|
|
// Agent-skills cite sibling agents and /axiom: commands the same way routers do,
|
|
// and Codex has neither — so they need the same translation.
|
|
bashNote + rewriteAgentInvokesForCodex(body.trim()),
|
|
'',
|
|
].join('\n');
|
|
|
|
const destDir = path.join(OUTPUT_SKILLS, skillName);
|
|
fs.mkdirSync(destDir, { recursive: true });
|
|
fs.writeFileSync(path.join(destDir, 'SKILL.md'), skillContent);
|
|
|
|
// Generate openai.yaml for the skill too.
|
|
//
|
|
// `policy.allow_implicit_invocation: false` is what actually makes an auditor
|
|
// explicit-invoke-only on Codex. The `disable-model-invocation: true` frontmatter
|
|
// key above does NOT: Codex's skill frontmatter struct deserializes only name,
|
|
// description, and metadata, with no deny_unknown_fields, so serde drops the key
|
|
// silently — and allows_implicit_invocation() reads the sidecar policy alone,
|
|
// defaulting to TRUE when absent (codex-rs/skills/src/model.rs). Emitting the key
|
|
// without the policy block left all 42 auditors competing for implicit activation
|
|
// on every prompt — the exact routing regression the design meant to prevent,
|
|
// failing silently in both directions (Axiom-izi).
|
|
//
|
|
// Keep the frontmatter key: Cursor honors that spelling and also reads
|
|
// .agents/skills/, so one directory carrying both controls covers Claude Code,
|
|
// Cursor, and Codex.
|
|
//
|
|
// default_prompt is required for a skill that no longer surfaces itself: it must
|
|
// name the skill as `$skill-name`, which is how a user invokes an explicit-only
|
|
// skill. Codex's own review-agent sample pairs the two for this reason.
|
|
const displayName = toDisplayName(skillName);
|
|
const shortDesc = toShortDescription(description);
|
|
const yaml = [
|
|
'interface:',
|
|
` display_name: "${displayName}"`,
|
|
` short_description: "${shortDesc}"`,
|
|
` default_prompt: "Use $${skillName} to ${toDefaultPromptTask(skillName)}"`,
|
|
'policy:',
|
|
' allow_implicit_invocation: false',
|
|
'',
|
|
].join('\n');
|
|
const agentsDir = path.join(destDir, 'agents');
|
|
fs.mkdirSync(agentsDir, { recursive: true });
|
|
fs.writeFileSync(path.join(agentsDir, 'openai.yaml'), yaml);
|
|
|
|
agentsCopied++;
|
|
}
|
|
|
|
// Summary
|
|
const skipped = EXCLUDE_SKILLS.size;
|
|
console.log(`axiom-codex built: ${copied} skills (${skipped} routers excluded) + ${agentsCopied} agent-skills + ${hookScriptsCopied} hook scripts, v${version}`);
|