diff --git a/package.json b/package.json index 0270f6c..f36e1e7 100644 --- a/package.json +++ b/package.json @@ -11,7 +11,7 @@ "scripts": { "build": "bun run gen:skill-docs", "gen:skill-docs": "bun run scripts/gen-skill-docs.ts", - "test": "bun test test/skill-validation.test.ts test/rego-policy.test.ts test/bin-smoke.test.ts test/touchfiles.test.ts test/architecture.test.ts test/comply-start.test.ts", + "test": "bun test test/skill-validation.test.ts test/rego-policy.test.ts test/bin-smoke.test.ts test/touchfiles.test.ts test/architecture.test.ts test/comply-start.test.ts test/codex-generation.test.ts test/codex-setup.test.ts test/codex-smoke.test.ts", "test:evals": "EVALS=1 bun test test/skill-e2e.test.ts", "test:evals:all": "EVALS_ALL=1 bun test test/skill-e2e.test.ts", "skill:check": "bun run scripts/skill-check.ts", diff --git a/scripts/gen-skill-docs.ts b/scripts/gen-skill-docs.ts index 4b074ff..232cb3e 100644 --- a/scripts/gen-skill-docs.ts +++ b/scripts/gen-skill-docs.ts @@ -14,6 +14,11 @@ import type { FrameworkDefinition } from "../frameworks/schema"; const ROOT = path.resolve(import.meta.dir, ".."); const DRY_RUN = process.argv.includes("--dry-run"); +const HOST_IDX = process.argv.indexOf("--host"); +const HOST = + HOST_IDX !== -1 ? (process.argv[HOST_IDX + 1] ?? "claude") : "claude"; +const CODEX_OUTPUT_ROOT = + process.env.EMDASH_CODEX_OUTPUT_ROOT ?? path.join(ROOT, ".agents", "skills"); // ─── Framework Loading ────────────────────────────────────── @@ -1765,25 +1770,115 @@ function findTemplates(): string[] { return templates; } +// ─── Codex Post-Processing ───────────────────────────────── + +function stripCodexFrontmatter(content: string): string { + const fmStart = content.indexOf("---"); + const fmEnd = content.indexOf("---", fmStart + 3); + if (fmStart === -1 || fmEnd === -1) return content; + + const before = content.slice(0, fmStart + 4); + const fm = content.slice(fmStart + 4, fmEnd); + const after = content.slice(fmEnd); + + let cleaned = fm.replace(/^version:.*\n/m, ""); + cleaned = cleaned.replace(/^allowed-tools:\n( - .*\n)*/m, ""); + + return before + cleaned + after; +} + +function replaceClaudePaths(content: string): string { + return content + .replace(/~\/\.claude\/skills\/em-dash/g, "~/.codex/skills/em-dash") + .replace(/\.claude\/skills\/em-dash/g, ".agents/skills/em-dash"); +} + +function extractFrontmatterField(content: string, field: string): string { + const fmStart = content.indexOf("---"); + const fmEnd = content.indexOf("---", fmStart + 3); + if (fmStart === -1 || fmEnd === -1) return ""; + const fm = content.slice(fmStart + 4, fmEnd); + + if (field === "description") { + const multiLine = fm.match(/^description:\s*\|?\s*\n((?: .*\n)*)/m); + if (multiLine) return multiLine[1].replace(/^ /gm, "").trim(); + const inlineMatch = fm.match(/^description:\s*(.+)$/m); + return inlineMatch ? inlineMatch[1].trim() : ""; + } + + const match = fm.match(new RegExp(`^${field}:\\s*(.+)$`, "m")); + return match ? match[1].trim() : ""; +} + +function generateOpenAIYaml(skillName: string, description: string): string { + let shortDesc = description.replace(/\n/g, " ").trim(); + if (shortDesc.length > 120) shortDesc = `${shortDesc.slice(0, 117)}...`; + const escaped = shortDesc.replace(/"/g, '\\"'); + return [ + "interface:", + ` display_name: "${skillName}"`, + ` short_description: "${escaped}"`, + ` default_prompt: "Use ${skillName} for this task."`, + "policy:", + " allow_implicit_invocation: true", + "", + ].join("\n"); +} + +// ─── Generation Loop ─────────────────────────────────────── + let hasChanges = false; for (const tmplPath of findTemplates()) { - const { outputPath, content } = processTemplate(tmplPath); - const relOutput = path.relative(ROOT, outputPath); - - if (DRY_RUN) { - const existing = fs.existsSync(outputPath) - ? fs.readFileSync(outputPath, "utf-8") - : ""; - if (existing !== content) { - console.log(`STALE: ${relOutput}`); - hasChanges = true; + const { outputPath: claudeOutputPath, content: claudeContent } = + processTemplate(tmplPath); + + if (HOST === "claude") { + const relOutput = path.relative(ROOT, claudeOutputPath); + if (DRY_RUN) { + const existing = fs.existsSync(claudeOutputPath) + ? fs.readFileSync(claudeOutputPath, "utf-8") + : ""; + if (existing !== claudeContent) { + console.log(`STALE: ${relOutput}`); + hasChanges = true; + } else { + console.log(`FRESH: ${relOutput}`); + } } else { - console.log(`FRESH: ${relOutput}`); + fs.writeFileSync(claudeOutputPath, claudeContent); + console.log(`GENERATED: ${relOutput}`); } } else { - fs.writeFileSync(outputPath, content); - console.log(`GENERATED: ${relOutput}`); + const content = replaceClaudePaths(stripCodexFrontmatter(claudeContent)); + const skillName = path.basename(path.dirname(tmplPath)); + const skillDir = path.join(CODEX_OUTPUT_ROOT, skillName); + const outputPath = path.join(skillDir, "SKILL.md"); + const yamlPath = path.join(skillDir, "agents", "openai.yaml"); + const relOutput = path.relative(CODEX_OUTPUT_ROOT, outputPath); + + const description = extractFrontmatterField(claudeContent, "description"); + const yamlContent = generateOpenAIYaml(skillName, description); + + if (DRY_RUN) { + const existingMd = fs.existsSync(outputPath) + ? fs.readFileSync(outputPath, "utf-8") + : ""; + const existingYaml = fs.existsSync(yamlPath) + ? fs.readFileSync(yamlPath, "utf-8") + : ""; + if (existingMd !== content || existingYaml !== yamlContent) { + console.log(`STALE: ${relOutput}`); + hasChanges = true; + } else { + console.log(`FRESH: ${relOutput}`); + } + } else { + fs.mkdirSync(path.join(skillDir, "agents"), { recursive: true }); + fs.writeFileSync(outputPath, content); + fs.writeFileSync(yamlPath, yamlContent); + console.log(`GENERATED: ${relOutput}`); + } } } diff --git a/setup b/setup index 1fcd1bb..4f83dfc 100755 --- a/setup +++ b/setup @@ -1,96 +1,263 @@ #!/usr/bin/env bash -# em-dash setup — register skills with Claude Code -set -e +# em-dash setup — register skills with Claude Code / Codex +set -euo pipefail -EMDASH_DIR="$(cd "$(dirname "$0")" && pwd)" -SKILLS_DIR="$(dirname "$EMDASH_DIR")" +INSTALL_EMDASH_DIR="$(cd "$(dirname "$0")" && pwd)" +SOURCE_EMDASH_DIR="$(cd "$(dirname "$0")" && pwd -P)" +INSTALL_SKILLS_DIR="$(dirname "$INSTALL_EMDASH_DIR")" +CODEX_SKILLS="$HOME/.codex/skills" +CODEX_EMDASH="$CODEX_SKILLS/em-dash" +HOST="claude" +CODEX_GENERATED_SKILLS_DIR="${EMDASH_CODEX_OUTPUT_ROOT:-$SOURCE_EMDASH_DIR/.agents/skills}" + +while [ $# -gt 0 ]; do + case "$1" in + --host) + [ $# -lt 2 ] && echo "Missing value for --host (expected claude or codex)" >&2 && exit 1 + HOST="$2" + shift 2 + ;; + --host=*) + HOST="${1#--host=}" + shift + ;; + *) + shift + ;; + esac +done + +case "$HOST" in + claude|codex) ;; + *) + echo "Unknown --host value: $HOST (expected claude or codex)" >&2 + exit 1 + ;; +esac echo "em-dash setup" echo "=============" -# 1. Migrate data directory if upgrading from hipaa-audit if [ -d "$HOME/.hipaa-audit" ] && [ ! -d "$HOME/.em-dash" ]; then echo " migrating ~/.hipaa-audit/ → ~/.em-dash/ ..." cp -R "$HOME/.hipaa-audit" "$HOME/.em-dash" echo " migration complete (old directory preserved at ~/.hipaa-audit/)" fi -# 2. Check for bun (needed for gen-skill-docs) +BUN_AVAILABLE=0 if command -v bun >/dev/null 2>&1; then + BUN_AVAILABLE=1 echo " bun: $(bun --version)" else - echo " bun: not installed (optional — needed for template regeneration)" - echo " Install: curl -fsSL https://bun.sh/install | bash" + if [ "$HOST" = "codex" ]; then + echo "Error: bun is required for Codex skill generation." >&2 + echo "Install it: curl -fsSL https://bun.sh/install | bash" >&2 + exit 1 + fi + echo " bun: not installed (optional for Claude-only linking)" fi -# 3. Install dependencies (needed for canonicalize, Ed25519 signing) -if command -v bun >/dev/null 2>&1; then - (cd "$EMDASH_DIR" && bun install --frozen-lockfile 2>/dev/null || bun install) >/dev/null 2>&1 +if [ "$BUN_AVAILABLE" -eq 1 ]; then + (cd "$SOURCE_EMDASH_DIR" && bun install --frozen-lockfile 2>/dev/null || bun install) >/dev/null 2>&1 echo " dependencies: installed" fi -# 4. Make bin scripts executable -chmod +x "$EMDASH_DIR"/bin/* 2>/dev/null || true +chmod +x "$SOURCE_EMDASH_DIR"/bin/* 2>/dev/null || true echo " bin scripts: executable" -# 4. Create global state directory -mkdir -p "$HOME/.em-dash/projects" +mkdir -p "$HOME/.em-dash/projects" "$HOME/.em-dash/repos" echo " state dir: ~/.em-dash/" -# 5. Link skill directories into Claude Code skills -SKILLS_BASENAME="$(basename "$SKILLS_DIR")" -if [ "$SKILLS_BASENAME" = "skills" ]; then - LINKED=() - for skill_dir in "$EMDASH_DIR"/skills/*/; do - if [ -f "$skill_dir/SKILL.md" ] || [ -f "$skill_dir/SKILL.md.tmpl" ]; then +migrate_direct_codex_install() { + local source_dir="$1" + local codex_dir="$2" + local migrated_dir="$HOME/.em-dash/repos/em-dash" + + [ "$source_dir" = "$codex_dir" ] || return 0 + [ -L "$source_dir" ] && return 0 + + mkdir -p "$(dirname "$migrated_dir")" + if [ -e "$migrated_dir" ] && [ "$migrated_dir" != "$source_dir" ]; then + echo "em-dash setup failed: direct Codex install detected at $source_dir" >&2 + echo "A migrated repo already exists at $migrated_dir; move one of them aside and rerun setup." >&2 + exit 1 + fi + + echo " migrating direct Codex install to $migrated_dir ..." + mv "$source_dir" "$migrated_dir" + SOURCE_EMDASH_DIR="$migrated_dir" + INSTALL_EMDASH_DIR="$migrated_dir" + INSTALL_SKILLS_DIR="$(dirname "$INSTALL_EMDASH_DIR")" +} + +generate_claude_skill_docs() { + [ "$BUN_AVAILABLE" -eq 1 ] || return 0 + echo "" + echo "Generating Claude SKILL.md files..." + (cd "$SOURCE_EMDASH_DIR" && bun run gen:skill-docs) +} + +generate_codex_skill_docs() { + [ "$BUN_AVAILABLE" -eq 1 ] || return 1 + echo "" + echo "Generating Codex skill docs..." + (cd "$SOURCE_EMDASH_DIR" && bun run gen:skill-docs -- --host codex) +} + +link_claude_skill_dirs() { + local emdash_dir="$1" + local skills_dir="$2" + local linked=() + + mkdir -p "$skills_dir" + for skill_dir in "$emdash_dir"/skills/*/; do + [ -d "$skill_dir" ] || continue + if [ -f "$skill_dir/SKILL.md" ]; then + local skill_name skill_name="$(basename "$skill_dir")" - target="$SKILLS_DIR/$skill_name" + local target="$skills_dir/$skill_name" if [ -L "$target" ] || [ ! -e "$target" ]; then ln -snf "em-dash/skills/$skill_name" "$target" - LINKED+=("$skill_name") + linked+=("$skill_name") fi fi done - # Also link the root (em-dash itself) so /comply works - target="$SKILLS_DIR/em-dash" - if [ -L "$target" ] || [ ! -e "$target" ]; then - ln -snf "em-dash" "$SKILLS_DIR/em-dash" 2>/dev/null || true + if [ ${#linked[@]} -gt 0 ]; then + echo " linked skills: ${linked[*]}" fi +} - if [ ${#LINKED[@]} -gt 0 ]; then - echo " linked skills: ${LINKED[*]}" +link_codex_skill_dirs() { + local skills_dir="$1" + local agents_dir="$CODEX_GENERATED_SKILLS_DIR" + local linked=() + + [ -d "$agents_dir" ] || generate_codex_skill_docs + + if [ ! -d "$agents_dir" ]; then + echo "em-dash setup failed: missing generated Codex skills in $agents_dir" >&2 + exit 1 fi - echo "" - echo "em-dash ready." -else - echo "" - echo "em-dash ready." - echo " (skipped skill symlinks — not inside .claude/skills/)" - echo " To install: clone this repo into ~/.claude/skills/ and re-run setup" + + mkdir -p "$skills_dir" + for skill_dir in "$agents_dir"/*/; do + [ -d "$skill_dir" ] || continue + if [ -f "$skill_dir/SKILL.md" ]; then + local skill_name + skill_name="$(basename "$skill_dir")" + [ "$skill_name" = "em-dash" ] && continue + local target="$skills_dir/$skill_name" + if [ -L "$target" ] || [ ! -e "$target" ]; then + ln -snf "$skill_dir" "$target" + linked+=("$skill_name") + fi + fi + done + + if [ ${#linked[@]} -gt 0 ]; then + echo " linked skills: ${linked[*]}" + fi +} + +create_codex_runtime_root() { + local emdash_dir="$1" + local codex_root="$2" + + if [ -L "$codex_root" ]; then + rm -f "$codex_root" + elif [ -d "$codex_root" ] && [ "$codex_root" != "$emdash_dir" ]; then + rm -rf "$codex_root" + fi + + mkdir -p "$codex_root" "$codex_root/skills/hipaa-audit" + + for asset in bin dashboard frameworks nist policies scripts templates node_modules; do + local src="$emdash_dir/$asset" + local dst="$codex_root/$asset" + if [ -d "$src" ]; then + ln -snf "$src" "$dst" + fi + done + + for file in package.json bun.lock; do + local src="$emdash_dir/$file" + local dst="$codex_root/$file" + if [ -f "$src" ]; then + ln -snf "$src" "$dst" + fi + done + + if [ -f "$emdash_dir/skills/hipaa-audit/questionnaire-library.json" ]; then + ln -snf "$emdash_dir/skills/hipaa-audit/questionnaire-library.json" \ + "$codex_root/skills/hipaa-audit/questionnaire-library.json" + fi +} + +SKILLS_BASENAME="$(basename "$INSTALL_SKILLS_DIR")" +SKILLS_PARENT_BASENAME="$(basename "$(dirname "$INSTALL_SKILLS_DIR")")" +CODEX_REPO_LOCAL=0 +if [ "$SKILLS_BASENAME" = "skills" ] && [ "$SKILLS_PARENT_BASENAME" = ".agents" ]; then + CODEX_REPO_LOCAL=1 fi -# 6. Generate SKILL.md files if bun is available -if command -v bun >/dev/null 2>&1; then +if [ "$HOST" = "claude" ]; then + generate_claude_skill_docs + + if [ "$SKILLS_BASENAME" = "skills" ] && [ "$SKILLS_PARENT_BASENAME" = ".claude" ]; then + link_claude_skill_dirs "$SOURCE_EMDASH_DIR" "$INSTALL_SKILLS_DIR" + echo "" + echo "em-dash ready (claude)." + else + echo "" + echo "em-dash ready (claude)." + echo " (skipped skill symlinks — not inside .claude/skills/)" + echo " To install globally: clone this repo into ~/.claude/skills/em-dash and rerun setup" + fi +fi + +if [ "$HOST" = "codex" ]; then + migrate_direct_codex_install "$SOURCE_EMDASH_DIR" "$CODEX_EMDASH" + # Recompute after migration may have moved SOURCE_EMDASH_DIR + if [ -z "${EMDASH_CODEX_OUTPUT_ROOT:-}" ]; then + CODEX_GENERATED_SKILLS_DIR="$SOURCE_EMDASH_DIR/.agents/skills" + fi + generate_codex_skill_docs + + if [ "$CODEX_REPO_LOCAL" -eq 1 ]; then + CODEX_SKILLS="$INSTALL_SKILLS_DIR" + CODEX_EMDASH="$INSTALL_EMDASH_DIR" + fi + + mkdir -p "$CODEX_SKILLS" + if [ "$CODEX_REPO_LOCAL" -eq 0 ]; then + create_codex_runtime_root "$SOURCE_EMDASH_DIR" "$CODEX_EMDASH" + fi + link_codex_skill_dirs "$CODEX_SKILLS" + echo "" - echo "Generating SKILL.md files..." - (cd "$EMDASH_DIR" && bun run gen:skill-docs) + echo "em-dash ready (codex)." + if [ "$CODEX_REPO_LOCAL" -eq 1 ]; then + echo " install: repo-local" + else + echo " install: global" + fi + echo " codex skills: $CODEX_SKILLS" fi echo "" -echo "Skills (7):" -echo " /comply — compliance status + router" -echo " /comply-auto — autopilot: scan → fix → assess → next control" -echo " /comply-assess — focused interview, one NIST control at a time" -echo " /comply-scan — focused scan, one NIST control at a time" -echo " /comply-fix — remediate failed controls" -echo " /comply-report — compliance report + signed audit packet" -echo " /comply-breach — incident response workflow" -echo "" -echo "Utilities (6):" -echo " bin/comply-db — SQLite compliance database (init, status, query)" -echo " bin/comply-attest — Ed25519 attestation signing" -echo " bin/comply-verify — attestation verification" -echo " bin/comply-audit-packet — signed audit packet generation" -echo " bin/comply-evidence-hash — SHA-256 evidence hashing" -echo " bin/comply-slug — project slug generation" +echo "Skills (14):" +echo " comply — compliance status + router" +echo " comply-auto — autopilot: scan → fix → assess → next control" +echo " comply-assess — focused interview, one control at a time" +echo " comply-explain — explain any standard in plain English" +echo " comply-scan — focused scan, one control at a time" +echo " comply-fix — remediate failed controls" +echo " comply-report — compliance report + signed audit packet" +echo " comply-breach — incident response workflow" +echo " hipaa — HIPAA onramp + assessment flow" +echo " hipaa-audit — mock OCR audit" +echo " gdpr — GDPR framework" +echo " soc2 — SOC 2 framework" +echo " pci-dss — PCI DSS framework" +echo " em-dashboard — visual compliance dashboard" diff --git a/test/codex-generation.test.ts b/test/codex-generation.test.ts new file mode 100644 index 0000000..5fad7b6 --- /dev/null +++ b/test/codex-generation.test.ts @@ -0,0 +1,90 @@ +import { afterAll, describe, expect, test } from "bun:test"; +import * as fs from "node:fs"; +import * as os from "node:os"; +import * as path from "node:path"; + +const ROOT = path.resolve(import.meta.dir, ".."); +const bunPath = Bun.which("bun") ?? process.argv[0]; +const skillDirs = fs + .readdirSync(path.join(ROOT, "skills"), { withFileTypes: true }) + .filter((entry) => entry.isDirectory() && !entry.name.startsWith(".")) + .map((entry) => entry.name) + .sort(); + +const tempDirs: string[] = []; + +function makeTempDir(prefix: string): string { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), prefix)); + tempDirs.push(dir); + return dir; +} + +function runGenerator(outputRoot: string, args: string[] = []) { + return Bun.spawnSync([bunPath, "run", "scripts/gen-skill-docs.ts", ...args], { + cwd: ROOT, + env: { ...process.env, EMDASH_CODEX_OUTPUT_ROOT: outputRoot }, + stdout: "pipe", + stderr: "pipe", + }); +} + +function text(bytes: Uint8Array): string { + return new TextDecoder().decode(bytes).trim(); +} + +afterAll(() => { + for (const dir of tempDirs) fs.rmSync(dir, { recursive: true, force: true }); +}); + +describe("Codex skill generation", () => { + test("writes Codex wrappers and metadata into the configured output root", () => { + const outputRoot = makeTempDir("emdash-codex-gen-"); + const proc = runGenerator(outputRoot, ["--host", "codex"]); + expect(proc.exitCode).toBe(0); + + for (const skillDir of skillDirs) { + expect(fs.existsSync(path.join(outputRoot, skillDir, "SKILL.md"))).toBe( + true, + ); + expect( + fs.existsSync(path.join(outputRoot, skillDir, "agents", "openai.yaml")), + ).toBe(true); + } + }); + + test("strips Claude-only frontmatter fields from Codex output", () => { + const outputRoot = makeTempDir("emdash-codex-frontmatter-"); + const proc = runGenerator(outputRoot, ["--host", "codex"]); + expect(proc.exitCode).toBe(0); + + const content = fs.readFileSync( + path.join(outputRoot, "hipaa-audit", "SKILL.md"), + "utf-8", + ); + const fmEnd = content.indexOf("\n---", 4); + const fm = content.slice(4, fmEnd); + + expect(fm).toContain("name: hipaa-audit"); + expect(fm).toContain("description:"); + expect(fm).not.toContain("version:"); + expect(fm).not.toContain("allowed-tools:"); + expect(content).not.toContain(".claude/skills/em-dash"); + }); + + test("dry-run is clean after Codex generation", () => { + const outputRoot = makeTempDir("emdash-codex-dryrun-"); + expect(runGenerator(outputRoot, ["--host", "codex"]).exitCode).toBe(0); + + const dryRun = runGenerator(outputRoot, ["--host", "codex", "--dry-run"]); + expect(dryRun.exitCode).toBe(0); + }); + + test("dry-run does not create files in an empty output root", () => { + const outputRoot = makeTempDir("emdash-codex-empty-"); + const dryRun = runGenerator(outputRoot, ["--host", "codex", "--dry-run"]); + + expect(dryRun.exitCode).toBe(1); + expect(text(dryRun.stdout)).toContain("STALE:"); + expect(fs.readdirSync(outputRoot).length).toBe(0); + }); +}); diff --git a/test/codex-setup.test.ts b/test/codex-setup.test.ts new file mode 100644 index 0000000..2866c71 --- /dev/null +++ b/test/codex-setup.test.ts @@ -0,0 +1,103 @@ +import { afterAll, describe, expect, test } from "bun:test"; +import * as fs from "node:fs"; +import * as os from "node:os"; +import * as path from "node:path"; + +const ROOT = path.resolve(import.meta.dir, ".."); +const tempDirs: string[] = []; + +function makeTempDir(prefix: string): string { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), prefix)); + tempDirs.push(dir); + return dir; +} + +function runSetup( + cwd: string, + home: string, + outputRoot: string, + scriptPath = path.join(ROOT, "setup"), +) { + return Bun.spawnSync(["bash", scriptPath, "--host", "codex"], { + cwd, + env: { ...process.env, HOME: home, EMDASH_CODEX_OUTPUT_ROOT: outputRoot }, + stdout: "pipe", + stderr: "pipe", + }); +} + +function realPath(target: string): string { + return fs.realpathSync(target); +} + +afterAll(() => { + for (const dir of tempDirs) fs.rmSync(dir, { recursive: true, force: true }); +}); + +describe("setup --host codex", () => { + test("creates a minimal global Codex runtime root and top-level skill links", () => { + const home = makeTempDir("emdash-codex-home-"); + const outputRoot = makeTempDir("emdash-codex-output-"); + const proc = runSetup(ROOT, home, outputRoot); + expect(proc.exitCode).toBe(0); + + const codexSkills = path.join(home, ".codex", "skills"); + const runtimeRoot = path.join(codexSkills, "em-dash"); + + expect(fs.existsSync(path.join(runtimeRoot, "bin"))).toBe(true); + expect(fs.existsSync(path.join(runtimeRoot, "dashboard"))).toBe(true); + expect(fs.existsSync(path.join(runtimeRoot, "frameworks"))).toBe(true); + expect(fs.existsSync(path.join(runtimeRoot, "nist"))).toBe(true); + expect(fs.existsSync(path.join(runtimeRoot, "policies"))).toBe(true); + expect(fs.existsSync(path.join(runtimeRoot, "scripts"))).toBe(true); + expect(fs.existsSync(path.join(runtimeRoot, "package.json"))).toBe(true); + expect( + fs.existsSync( + path.join( + runtimeRoot, + "skills", + "hipaa-audit", + "questionnaire-library.json", + ), + ), + ).toBe(true); + expect( + fs.existsSync(path.join(runtimeRoot, "skills", "comply", "SKILL.md")), + ).toBe(false); + + expect(fs.existsSync(path.join(codexSkills, "comply", "SKILL.md"))).toBe( + true, + ); + expect(realPath(path.join(codexSkills, "comply", "SKILL.md"))).toBe( + realPath(path.join(outputRoot, "comply", "SKILL.md")), + ); + }); + + test("links Codex skills into a repo-local .agents/skills install without creating a global root", () => { + const home = makeTempDir("emdash-codex-home-local-"); + const projectDir = makeTempDir("emdash-codex-project-"); + const outputRoot = makeTempDir("emdash-codex-output-local-"); + const localSkillsDir = path.join(projectDir, ".agents", "skills"); + + fs.mkdirSync(localSkillsDir, { recursive: true }); + fs.symlinkSync(ROOT, path.join(localSkillsDir, "em-dash")); + + const proc = runSetup( + projectDir, + home, + outputRoot, + path.join(projectDir, ".agents", "skills", "em-dash", "setup"), + ); + expect(proc.exitCode).toBe(0); + + expect(fs.existsSync(path.join(localSkillsDir, "comply", "SKILL.md"))).toBe( + true, + ); + expect(realPath(path.join(localSkillsDir, "comply", "SKILL.md"))).toBe( + realPath(path.join(outputRoot, "comply", "SKILL.md")), + ); + expect(fs.existsSync(path.join(home, ".codex", "skills", "em-dash"))).toBe( + false, + ); + }); +}); diff --git a/test/codex-smoke.test.ts b/test/codex-smoke.test.ts new file mode 100644 index 0000000..96fd31b --- /dev/null +++ b/test/codex-smoke.test.ts @@ -0,0 +1,87 @@ +import { afterAll, describe, expect, test } from "bun:test"; +import * as fs from "node:fs"; +import * as os from "node:os"; +import * as path from "node:path"; + +const ROOT = path.resolve(import.meta.dir, ".."); +const CODEX_SMOKE = process.env.CODEX_SMOKE === "1"; +const CODEX_BIN = Bun.which("codex"); +const tempDirs: string[] = []; + +function makeTempDir(prefix: string): string { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), prefix)); + tempDirs.push(dir); + return dir; +} + +afterAll(() => { + for (const dir of tempDirs) fs.rmSync(dir, { recursive: true, force: true }); +}); + +if (!CODEX_SMOKE || !CODEX_BIN) { + describe("codex smoke (skipped)", () => { + test.skip("set CODEX_SMOKE=1 and ensure codex is installed to run", () => {}); + }); +} else { + describe("codex smoke", () => { + test("repo-local install is usable by codex exec", () => { + const home = makeTempDir("emdash-codex-smoke-home-"); + const projectDir = makeTempDir("emdash-codex-smoke-project-"); + const localSkillsDir = path.join(projectDir, ".agents", "skills"); + const outputFile = path.join(projectDir, "codex-last-message.txt"); + + fs.mkdirSync(localSkillsDir, { recursive: true }); + fs.symlinkSync(ROOT, path.join(localSkillsDir, "em-dash")); + + const smokeOutputRoot = makeTempDir("emdash-codex-smoke-output-"); + const setupProc = Bun.spawnSync( + [ + "bash", + path.join(localSkillsDir, "em-dash", "setup"), + "--host", + "codex", + ], + { + cwd: projectDir, + env: { + ...process.env, + HOME: home, + EMDASH_CODEX_OUTPUT_ROOT: smokeOutputRoot, + }, + stdout: "pipe", + stderr: "pipe", + }, + ); + expect(setupProc.exitCode).toBe(0); + + const prompt = + "Use the comply-breach skill. Return only the repo-local Codex refresh command from the skill instructions."; + const codexProc = Bun.spawnSync( + [ + CODEX_BIN, + "exec", + "--skip-git-repo-check", + "--sandbox", + "read-only", + "--cd", + projectDir, + "--output-last-message", + outputFile, + prompt, + ], + { + cwd: projectDir, + env: { ...process.env, HOME: home }, + stdout: "pipe", + stderr: "pipe", + }, + ); + + expect(codexProc.exitCode).toBe(0); + const message = fs.readFileSync(outputFile, "utf-8").trim(); + expect(message).toContain( + "cd .agents/skills/em-dash && ./setup --host codex", + ); + }, 120000); + }); +}