diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0347fa2..a5eaa3c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -28,6 +28,7 @@ jobs: - run: npm ci - run: node bin/toh-cli.js --version - run: node bin/toh-cli.js --help + - run: npm test - run: npm run list - run: npm run status - run: npm pack --dry-run diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index dbcac6f..0d07177 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -42,6 +42,7 @@ jobs: - run: npm ci - run: node bin/toh-cli.js --version - run: node bin/toh-cli.js --help + - run: npm test - run: npm run list - run: npm pack --dry-run diff --git a/CHANGELOG.md b/CHANGELOG.md index 4fe4dc6..9ae41e0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,14 @@ All notable changes to Toh Framework will be documented in this file. +## [Unreleased] + +#### Added + +- **Native Codex agents** — every Toh agent in `.toh/agents/` is now also installed as a project-scoped Codex custom agent in `.codex/agents/.toml` (`name`, `description`, `model_reasoning_effort`, `sandbox_mode`, `developer_instructions`), so Codex can delegate to `ui-builder`, `plan-orchestrator` and friends natively. The files deliberately carry **no `model` key**: they inherit the parent session's model, so one config choice governs every agent and a future model rename never strands an install. Reasoning effort comes from a new `modelIntent` frontmatter key (`lightweight | implementation | planning | review`, falling back to the Claude tier), and agents whose tool allowlist has no write tool get Codex's `read-only` sandbox. Ownership is tracked by sha256 in `.codex/toh-framework.json`: a file you edited or created is never overwritten or removed. AGENTS.md now points Codex at `$toh-` skill invocation and the native agents. Contributed by @pcbimon in [PR #3](https://github.com/wasintoh/toh-framework/pull/3); reshaped in review so `.agents/skills/` keeps a single writer (shared.js), an existing `.codex/config.toml` is still never modified, and the codex capability profile stays the probed v2.1.1 floor. +- **`toh uninstall --ide codex`** — removes just the native agent files this installer wrote (hash-verified, backed up first) and their manifest; AGENTS.md, `.codex/config.toml` and `.toh/` stay. The full uninstall also knows the new paths. +- **Test suite** — `npm test` runs `tests/codex.test.js` (node:test, in-band): install layout, TOML shape, single-writer invariant across IDE order, config.toml untouched, ownership by hash, AGENTS.md idempotency and budget, both uninstall paths. First automated tests in the repo; CI now runs them. + ## [2.1.1] - 2026-08-26 ### 🩹 Patch: Updates That Respect Your Work diff --git a/CLAUDE.md b/CLAUDE.md index 8847be3..57f39b9 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -7,7 +7,7 @@ Guide for a Claude Code agent developing the framework itself. Repo-only: not in Toh Framework ("Type Once, Have it all") is the npm package `toh-framework` (v2.1.0, MIT, ESM, Node >= 18, no build step) that installs an AI-orchestration development system into 6 IDEs: -Claude Code, Cursor (2.4+), Antigravity (agy CLI + IDE), Codex CLI, ZCode (Z.ai), and — +Claude Code, Cursor (2.4+), Antigravity (agy CLI + IDE), Codex (CLI + desktop app), ZCode (Z.ai), and — Enterprise-only, behind `--legacy-gemini` — Gemini CLI (consumer service shut down 2026-06-18). North Star: a non-technical person types one sentence, approves once ("Go"), and THE TOH LOOP @@ -30,7 +30,8 @@ All npm scripts wrap `node bin/toh-cli.js `, which lazy-loads `installer/*. ## Verification protocol -There is NO automated test suite — .github/workflows/ci.yml only smoke-tests the CLI. Verify by running for real: +`npm test` runs tests/codex.test.js (node:test, in-band via tests/run.js; TOH_QUIET=1 silences ora) — +it covers the Codex surfaces only. Everything else is still verified by running for real: 1. Run what you touched — install into a scratch dir, `npm run list`/`status`, `npm pack --dry-run`. Inspect the generated output (.toh/, .claude/, .cursor/rules/ + .cursor/agents/, AGENTS.md + @@ -69,6 +70,11 @@ skills converted from the TOML; throws on unparseable sources): - codex.js → one root AGENTS.md between TOH-FRAMEWORK-START/END markers — compact agent roster + indexed command table; bodies read at runtime from .toh/. Hard-asserts the block <=24 KiB (Codex silently truncates at 32 KiB) and emits .codex/config.toml raising project_doc_max_bytes, never overwriting an existing one. + v2.2: also writes one native Codex agent per Toh agent to .codex/agents/.toml — NO `model` + key (inherits the session), `model_reasoning_effort` from the agent's `modelIntent` frontmatter, + `sandbox_mode = "read-only"` when the tool allowlist has no write tool. Ownership by sha256 in + .codex/toh-framework.json: an edited or user-created file is never overwritten or removed. + It never writes .agents/skills/ — shared.js is the single writer for that directory. - zcode.js → thin by design. ZCode reads the same open surfaces, so it writes NO `.zcode/`: it reuses codex.js's exported writeAgentsMd() for AGENTS.md (ide 'zcode' swaps three sentences) and shared.js writeAgentsCommands() for `.agents/commands/` — 14 native `/toh-*` slash commands, the diff --git a/README.md b/README.md index a0370e8..3927970 100644 --- a/README.md +++ b/README.md @@ -55,7 +55,7 @@ Changed your mind? `npx toh-framework uninstall` shows you exactly what it will | 🧠 **Claude Code** | ✅ Full Support | Native subagents + skills preload, Stop hook, slash commands & shortcuts | | 📝 **Cursor (2.4+)** | ✅ Full Support | Native subagents (`.cursor/agents/`), skills via `.agents/skills/`, always-on rules | | 🛰️ **Antigravity CLI (agy) + IDE** | ✅ Full Support | `.agents/` rules + skills + workflows + subagents + Stop hook | -| 🤖 **Codex** — CLI + Codex desktop app (ChatGPT app) | ✅ Supported | Compact AGENTS.md + repo-level skills | +| 🤖 **Codex** — CLI + Codex desktop app (ChatGPT app) | ✅ Supported | Compact AGENTS.md + repo-level skills + native agents in `.codex/agents/` | | 💠 **ZCode (Z.ai)** | ✅ Supported | AGENTS.md + `.agents/skills/` + native `/toh-*` in `.agents/commands/` | | 💎 **Gemini CLI** | 🏢 Legacy | Enterprise/GCP only, behind `--legacy-gemini` | @@ -236,10 +236,17 @@ agy ```bash codex -# Same commands — AGENTS.md teaches Codex the full command set +# Invoke a Toh workflow as a native Codex skill ($ + name), or browse with /skills +$toh-vibe Inventory management system + +# Plain /toh-* text works too — AGENTS.md teaches Codex the full command set /toh-vibe Inventory management system ``` +The 8 Toh agents are also installed as native Codex agents in `.codex/agents/*.toml` +(generated from `.toh/agents/`, ownership-tracked so your edits are never overwritten). +They inherit the model of your session and carry only a reasoning-effort hint per role. + ### ZCode (Z.ai) Open the project in the ZCode app, or run the bundled CLI: diff --git a/bin/toh-cli.js b/bin/toh-cli.js index 8ca2687..b716d48 100755 --- a/bin/toh-cli.js +++ b/bin/toh-cli.js @@ -55,7 +55,13 @@ program .option('--legacy-cursorrules', 'Also write the legacy root .cursorrules file (very old Cursor versions)') .action(async (options) => { const { install } = await import('../installer/install.js'); - await install(options); + try { + await install(options); + } catch (error) { + // Hard-budget aborts already printed their explanation (error.reported). + if (!error.reported) console.error(chalk.red(`\n✖ Installation failed: ${error.message}\n`)); + process.exit(1); + } }); // Uninstall command — the counterpart to install. @@ -70,6 +76,8 @@ program .option('-y, --yes', 'Skip the confirmation question (for scripts)') .option('--all', 'ALSO delete your own plan, work log and project notes (a backup copy is saved first)') .option('--verbose', 'List every file in the preview instead of a per-tool summary') + .option('-i, --ide ', 'Remove one IDE surface only (currently: codex = the native .codex/agents/ files). Omit for the full uninstall') + .option('--no-backup', 'With --ide: skip the backup copy of removed files') .action(async (options) => { const { uninstall } = await import('../installer/uninstall.js'); const code = await uninstall(options); diff --git a/docs/README-TH.md b/docs/README-TH.md index 9083c36..892e1d1 100644 --- a/docs/README-TH.md +++ b/docs/README-TH.md @@ -63,7 +63,7 @@ AI จะเขียนแผนออกมาเป็นรายการ | 🧠 **Claude Code** | ✅ รองรับเต็ม | Native subagents + skills preload, Stop hook, slash commands และทางลัด | | 📝 **Cursor (2.4+)** | ✅ รองรับเต็ม | Native subagents (`.cursor/agents/`), skills ผ่าน `.agents/skills/`, rule แบบ always-on | | 🛰️ **Antigravity CLI (agy) + IDE** | ✅ รองรับเต็ม | `.agents/` rules + skills + workflows + subagents + Stop hook | -| 🤖 **Codex** (CLI + Codex desktop app / ChatGPT app) | ✅ รองรับ | AGENTS.md แบบกะทัดรัด + repo-level skills | +| 🤖 **Codex** (CLI + Codex desktop app / ChatGPT app) | ✅ รองรับ | AGENTS.md แบบกะทัดรัด + repo-level skills + native agents ใน `.codex/agents/` | | 💠 **ZCode (Z.ai)** | ✅ รองรับ | AGENTS.md + `.agents/skills/` + คำสั่ง `/toh-*` native ใน `.agents/commands/` | | 💎 **Gemini CLI** | 🏢 Legacy | เฉพาะ Enterprise/GCP ใช้ผ่าน `--legacy-gemini` | @@ -243,10 +243,17 @@ agy ```bash codex -# ใช้คำสั่งเดียวกัน AGENTS.md สอนชุดคำสั่งครบให้ Codex +# เรียก workflow ของ Toh เป็น skill ของ Codex ตรงๆ ด้วย $ ตามด้วยชื่อ หรือพิมพ์ /skills ดูทั้งหมด +$toh-vibe ระบบจัดการ inventory + +# พิมพ์ /toh-* แบบเดิมก็ยังใช้ได้ AGENTS.md สอนชุดคำสั่งครบให้ Codex อยู่แล้ว /toh-vibe ระบบจัดการ inventory ``` +agent ทั้ง 8 ตัวของ Toh ถูกติดตั้งเป็น native agent ของ Codex ด้วย อยู่ที่ `.codex/agents/*.toml` +สร้างจาก `.toh/agents/` และจำไว้ว่าไฟล์ไหนเป็นของเรา ไฟล์ที่คุณแก้เองจะไม่ถูกเขียนทับ +ทุกตัวใช้ model เดียวกับ session ของคุณ มีแค่ระดับ reasoning ที่ตั้งไว้ตามหน้าที่ของแต่ละตัว + ### ZCode (Z.ai) เปิดโปรเจคในแอป ZCode หรือใช้ CLI ที่มากับแอปก็ได้ diff --git a/installer/ide-handlers/codex.js b/installer/ide-handlers/codex.js index 60c0f37..85c450f 100644 --- a/installer/ide-handlers/codex.js +++ b/installer/ide-handlers/codex.js @@ -1,8 +1,9 @@ /** - * Codex CLI IDE Handler - * Creates AGENTS.md file for Codex CLI and Codex Web - * - * Codex uses AGENTS.md as "project memory" - automatically loaded on startup + * Codex IDE Handler (CLI + desktop app) + * Creates AGENTS.md (project memory, auto-loaded by Codex) and, since v2.2, + * one native Codex agent per Toh agent in .codex/agents/*.toml. + * The 14 /toh-* command skills and 23 framework skills reach Codex through + * the shared .agents/skills/ writer in shared.js — this file never writes there. */ import fs from 'fs-extra'; @@ -11,6 +12,8 @@ import { fileURLToPath } from 'url'; import yaml from 'js-yaml'; import { transformCommand, renderCapabilitiesSection, seedFileIfAbsent } from './shared.js'; import { probeCodexCapabilitiesCached } from './capability-probe.js'; +import crypto from 'crypto'; +import { parse as parseToml } from 'smol-toml'; // Hard budget for the TOH marker block inside AGENTS.md. Codex silently // truncates project docs at 32 KiB COMBINED (project_doc_max_bytes default), @@ -27,7 +30,7 @@ const AGENTS_MD_RUNTIMES = { memoryEN: 'This file serves as project memory for Codex (CLI and desktop app). It contains the Toh Framework configuration and agent definitions.', memoryTH: 'This file is project memory for Codex (CLI and desktop app) containing Toh Framework configuration and agent definitions', runtimeName: 'Codex', - commandHint: '' + commandHint: ' The 14 `/toh-*` workflows are also installed as Codex skills in `.agents/skills/` — invoke one explicitly with `$toh-` (e.g. `$toh-vibe`) or browse them with `/skills`; typing `/toh-vibe ...` as plain text works too. The 8 Toh agents are installed as native Codex agents in `.codex/agents/*.toml` (full specs stay in `.toh/agents/`); when you delegate to one, hand it a self-contained brief — custom agents cannot receive a full-history fork.' }, zcode: { memoryEN: 'This file serves as project memory for ZCode (Z.ai). It contains the Toh Framework configuration and agent definitions.', @@ -60,6 +63,268 @@ const __dirname = path.dirname(__filename); const pkg = JSON.parse(fs.readFileSync(path.join(__dirname, '../../package.json'), 'utf-8')); const VERSION = pkg.version; +// --------------------------------------------------------------------------- +// Native Codex agents (v2.2 — contributed by @pcbimon in PR #3, reshaped in +// review). Codex discovers project-scoped custom agents in .codex/agents/*.toml +// (developers.openai.com/codex/subagents); each Toh agent in .toh/agents/.md +// becomes one TOML file. Two deliberate choices: +// 1. NO `model` key. An agent file without `model` inherits the parent +// session's model (per the subagents doc), so the user's one config choice +// governs every agent and a future model rename never strands an install. +// Only `model_reasoning_effort` is set, from the agent's declared intent. +// 2. Ownership by hash. .codex/toh-framework.json records the sha256 of every +// agent file we wrote. A file whose hash no longer matches was edited (or +// created) by the user and is never overwritten or removed. +// --------------------------------------------------------------------------- +export const CODEX_AGENTS_DIR = path.join('.codex', 'agents'); +export const CODEX_MANIFEST_PATH = path.join('.codex', 'toh-framework.json'); +const MANIFEST_GENERATOR = 'toh-framework'; +const AGENT_NAME_RE = /^[a-z0-9-]{1,64}$/; +const MODEL_INTENTS = new Set(['lightweight', 'implementation', 'planning', 'review']); +// Same rule cursor.js uses for `readonly`: an allowlist with no write tool is +// read-only by design (root-cause-debugger: Read/Grep/Glob/Bash). +const READ_ONLY_TOOLS = new Set(['Read', 'Grep', 'Glob', 'Bash']); + +/** Toh model intent → Codex reasoning effort. The model itself is inherited. */ +export const CODEX_REASONING_EFFORT = Object.freeze({ + lightweight: 'low', + implementation: 'medium', + planning: 'high', + review: 'high' +}); + +function sha256(value) { + return crypto.createHash('sha256').update(value).digest('hex'); +} + +function agentRelPath(name) { + // Manifest keys are POSIX so the file is portable across platforms. + return `.codex/agents/${name}.toml`; +} + +function normalizeModelIntent(value) { + const intent = String(value || '').trim().toLowerCase().replaceAll('_', '-'); + const aliases = { + exploration: 'lightweight', + explore: 'lightweight', + scaffold: 'lightweight', + deep: 'planning', + 'deep-reasoning': 'planning', + security: 'review' + }; + return aliases[intent] || intent; +} + +/** + * `modelIntent` frontmatter wins; otherwise derive from the Claude tier so + * agents that predate the key still get sensible reasoning effort. + */ +export function resolveCodexModelIntent(frontmatter = {}) { + const explicit = normalizeModelIntent(frontmatter.modelIntent || frontmatter.model_intent); + if (MODEL_INTENTS.has(explicit)) return explicit; + const tier = String(frontmatter.model || '').trim().toLowerCase(); + if (tier === 'haiku') return 'lightweight'; + if (tier === 'opus') return 'planning'; + return 'implementation'; +} + +function parseAgentFile(raw, label) { + const match = raw.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n?([\s\S]*)$/); + if (!match) throw new Error(`[toh-framework] ${label} must start with YAML frontmatter.`); + try { + return { frontmatter: yaml.load(match[1]) || {}, body: match[2] }; + } catch (error) { + throw new Error(`[toh-framework] Invalid YAML frontmatter in ${label}: ${error.message}`); + } +} + +/** Read the installed Toh agents from .toh/agents/ (the runtime source of truth). */ +export async function readAgentCatalog(targetDir) { + const agentsDir = path.join(targetDir, '.toh', 'agents'); + if (!(await fs.pathExists(agentsDir))) return []; + const files = (await fs.readdir(agentsDir, { withFileTypes: true })) + .filter((entry) => entry.isFile() && entry.name.endsWith('.md') && entry.name !== 'README.md') + .map((entry) => entry.name) + .sort(); + const agents = []; + for (const file of files) { + const sourcePath = path.join(agentsDir, file); + const { frontmatter, body } = parseAgentFile(await fs.readFile(sourcePath, 'utf-8'), sourcePath); + const name = String(frontmatter.name || file.replace(/\.md$/, '')).trim(); + if (!AGENT_NAME_RE.test(name)) continue; + agents.push({ + name, + description: String(frontmatter.description || `${name} (Toh Framework agent)`).replace(/\s+/g, ' ').trim().slice(0, 1024), + body, + tools: Array.isArray(frontmatter.tools) ? frontmatter.tools.map(String) : [], + skills: Array.isArray(frontmatter.skills) ? frontmatter.skills.map(String) : [], + triggers: Array.isArray(frontmatter.triggers) ? frontmatter.triggers.map(String) : [], + modelIntent: resolveCodexModelIntent(frontmatter), + maxTurns: frontmatter.maxTurns + }); + } + return agents; +} + +function isReadOnlyAgent(agent) { + return agent.tools.length > 0 && agent.tools.every((tool) => READ_ONLY_TOOLS.has(tool)); +} + +/** One Toh agent → one Codex agent TOML document (validated before it is returned). */ +export function translateAgentToCodex(agent) { + const effort = CODEX_REASONING_EFFORT[agent.modelIntent] || CODEX_REASONING_EFFORT.implementation; + const skillRefs = agent.skills.length + ? `\nAssociated Toh skills (read before acting):\n${agent.skills.map((skill) => `- .toh/skills/${skill}/SKILL.md`).join('\n')}` + : ''; + const toolBoundary = agent.tools.length + ? `\nSource tool boundary: ${agent.tools.join(', ')}. Do not widen it.` + : ''; + const triggerHints = agent.triggers.length ? `\nRouting hints: ${agent.triggers.join('; ')}` : ''; + const turnHint = agent.maxTurns === undefined ? '' : `\nSource turn budget hint: ${agent.maxTurns}.`; + const instructions = `${agent.body.trim()} + +## Codex runtime contract +- Own only the task and files assigned by the parent. +- Return Status, Result, Evidence, Files, and Blockers. +- Run the supplied checkpoint; the parent re-runs it before changing .toh/plan.md. +- Keep dependent work sequential and return to the parent when complete.${skillRefs}${toolBoundary}${triggerHints}${turnHint}`; + + const content = [ + `# Generated by Toh Framework v${VERSION} from .toh/agents/${agent.name}.md`, + `# Toh model intent: ${agent.modelIntent}. No \`model\` key on purpose: the agent`, + `# inherits the parent session's model, so your one config choice governs it.`, + `name = ${JSON.stringify(agent.name)}`, + `description = ${JSON.stringify(agent.description)}`, + `model_reasoning_effort = ${JSON.stringify(effort)}`, + `sandbox_mode = ${JSON.stringify(isReadOnlyAgent(agent) ? 'read-only' : 'workspace-write')}`, + `developer_instructions = ${JSON.stringify(instructions.trim())}`, + '' + ].join('\n'); + try { + parseToml(content); + } catch (error) { + throw new Error(`[toh-framework] Generated Codex agent TOML for ${agent.name} is invalid: ${error.message}`); + } + return content; +} + +async function readCodexManifest(targetDir) { + const manifestPath = path.join(targetDir, CODEX_MANIFEST_PATH); + const empty = { generator: MANIFEST_GENERATOR, version: VERSION, agents: {} }; + if (!(await fs.pathExists(manifestPath))) return empty; + try { + const manifest = await fs.readJson(manifestPath); + if (manifest.generator !== MANIFEST_GENERATOR || typeof manifest.agents !== 'object' || manifest.agents === null) { + return empty; + } + return { ...empty, ...manifest }; + } catch { + return empty; + } +} + +async function writeCodexManifest(targetDir, manifest) { + const manifestPath = path.join(targetDir, CODEX_MANIFEST_PATH); + await fs.ensureDir(path.dirname(manifestPath)); + await fs.writeJson(manifestPath, manifest, { spaces: 2 }); +} + +async function fileMatchesHash(filePath, expectedHash) { + if (!expectedHash || !(await fs.pathExists(filePath))) return false; + try { + return sha256(await fs.readFile(filePath)) === expectedHash; + } catch { + return false; + } +} + +/** + * Write .codex/agents/.toml for every agent in .toh/agents/. + * Ownership-safe: a file we did not write (or that the user edited since) is + * kept as-is and reported; stale files we wrote for agents that no longer + * exist are removed only when still byte-identical to what we wrote. + */ +export async function installCodexAgents(targetDir) { + const agents = await readAgentCatalog(targetDir); + if (agents.length === 0) return { installed: [], kept: [], total: 0 }; + + const previous = await readCodexManifest(targetDir); + const previousAgents = previous.agents || {}; + const nextAgents = {}; + const wanted = new Set(agents.map((agent) => agentRelPath(agent.name))); + await fs.ensureDir(path.join(targetDir, CODEX_AGENTS_DIR)); + + for (const [rel, record] of Object.entries(previousAgents)) { + if (wanted.has(rel) || !rel.startsWith('.codex/agents/')) continue; + const filePath = path.join(targetDir, rel); + if (await fileMatchesHash(filePath, record.sha256)) await fs.remove(filePath); + } + + const installed = []; + const kept = []; + for (const agent of agents) { + const rel = agentRelPath(agent.name); + const filePath = path.join(targetDir, rel); + const content = translateAgentToCodex(agent); + const record = previousAgents[rel]; + const exists = await fs.pathExists(filePath); + if (exists && !(await fileMatchesHash(filePath, record?.sha256))) { + // Not ours, or edited since we wrote it — the user's file wins. + if (record) nextAgents[rel] = record; + kept.push(agent.name); + continue; + } + await fs.writeFile(filePath, content); + nextAgents[rel] = { sha256: sha256(content), source: `.toh/agents/${agent.name}.md`, modelIntent: agent.modelIntent }; + installed.push(agent.name); + } + await writeCodexManifest(targetDir, { ...previous, version: VERSION, agents: nextAgents }); + return { installed, kept, total: agents.length }; +} + +/** + * `toh uninstall --ide codex`: remove ONLY the native agent files this + * installer wrote (hash-verified) plus the manifest. AGENTS.md and + * .codex/config.toml are shared surfaces (ZCode reads AGENTS.md too) and are + * handled by the full uninstall planner in installer/uninstall.js. + */ +export async function uninstallCodex(targetDir, options = {}) { + const { dryRun = false, backup = true } = options; + const manifest = await readCodexManifest(targetDir); + const removed = []; + const kept = []; + for (const [rel, record] of Object.entries(manifest.agents || {})) { + if (!rel.startsWith('.codex/agents/') || !rel.endsWith('.toml')) continue; + const filePath = path.join(targetDir, rel); + if (!(await fs.pathExists(filePath))) continue; + if (await fileMatchesHash(filePath, record.sha256)) removed.push({ rel, filePath }); + else kept.push(rel); + } + const result = { + removedAgents: removed.map((item) => path.basename(item.rel, '.toml')).sort(), + keptAgents: kept.map((rel) => path.basename(rel, '.toml')).sort(), + dryRun, + backupPath: null + }; + if (dryRun) return result; + + if (backup && removed.length > 0) { + const backupDir = path.join(targetDir, '.toh-uninstall-backup', `codex-agents-${Date.now()}`); + for (const item of removed) { + const destination = path.join(backupDir, item.rel); + await fs.ensureDir(path.dirname(destination)); + await fs.copy(item.filePath, destination); + } + result.backupPath = backupDir; + } + for (const item of removed) await fs.remove(item.filePath); + const agentsDir = path.join(targetDir, CODEX_AGENTS_DIR); + if (await fs.pathExists(agentsDir) && (await fs.readdir(agentsDir)).length === 0) await fs.remove(agentsDir); + const manifestPath = path.join(targetDir, CODEX_MANIFEST_PATH); + if (await fs.pathExists(manifestPath)) await fs.remove(manifestPath); + return result; +} + /** * Create memory template files for the Memory System (v1.7.0) * Now includes architecture.md and components.md for Code Architecture Tracking @@ -397,7 +662,10 @@ export async function setupCodex(targetDir, srcDir, language = 'en', options = { ); } - return true; + // v2.2: native Codex agents, one TOML per Toh agent (ownership-safe). + const agents = await installCodexAgents(targetDir); + const keptNote = agents.kept.length ? `, ${agents.kept.length} kept as edited` : ''; + return `AGENTS.md + .codex/agents/ (${agents.installed.length}/${agents.total} agents${keptNote})`; } function generateAgentsMdEN(agentRoster, ide = 'codex', probedSubagents = false) { diff --git a/installer/install.js b/installer/install.js index bdc59b6..151f978 100644 --- a/installer/install.js +++ b/installer/install.js @@ -34,7 +34,9 @@ const VERSION = pkg.version; // math divide by zero and loop forever inside stop()/succeed(). On a // zero-width TTY fall back to plain non-animated output (isEnabled: false). const spin = (text) => { - const options = { text, discardStdin: false }; + // TOH_QUIET=1: no spinner output at all (the in-band test runner sets it — + // ora's stream writes corrupt node:test's IPC channel on Node 24). + const options = { text, discardStdin: false, isSilent: process.env.TOH_QUIET === '1' }; if (process.stderr.isTTY && !(process.stderr.columns > 0)) { options.isEnabled = false; // zero-width pty: plain output, no animation } @@ -167,14 +169,18 @@ export async function install(options) { // Validate target directory const spinner = spin('Validating target directory...').start(); if (!fs.existsSync(config.targetDir)) { - spinner.warn('Target directory does not exist'); - const { create } = await inquirer.prompt([{ - type: 'confirm', - name: 'create', - message: `Create directory ${config.targetDir}?`, - default: true - }]); - + // --quick is the non-interactive path: create the directory and proceed. + let create = quick; + if (!quick) { + spinner.warn('Target directory does not exist'); + ({ create } = await inquirer.prompt([{ + type: 'confirm', + name: 'create', + message: `Create directory ${config.targetDir}?`, + default: true + }])); + } + if (create) { fs.mkdirSync(config.targetDir, { recursive: true }); spinner.succeed('Directory created'); @@ -508,7 +514,10 @@ async function setupIDEWithSpinner(ideName, setupFn) { `\n✖ Installation aborted: ${ideName} failed a hard size-budget check (see above). ` + `Fix the generator and re-run the installer.\n` )); - process.exit(1); + // Thrown (not process.exit) so bin/toh-cli.js sets the exit code and the + // test suite can observe the abort. `reported` stops a second message. + error.reported = true; + throw error; } } } @@ -990,7 +999,11 @@ function printNextSteps(config) { // 9 chars green + 51 chars gray = 60 console.log(row(chalk.green(' codex') + chalk.gray(' - Start Codex CLI in project'.padEnd(51)))); // 13 chars green + 47 chars gray = 60 - console.log(row(chalk.green(' /toh-vibe') + chalk.gray(' - Create new project'.padEnd(47)))); + console.log(row(chalk.green(' $toh-vibe') + chalk.gray(' - Create new project (native skill)'.padEnd(47)))); + // 11 chars green + 49 chars gray = 60 + console.log(row(chalk.green(' /skills') + chalk.gray(' - Browse all 14 /toh-* skills'.padEnd(49)))); + // 18 chars green + 42 chars gray = 60 + console.log(row(chalk.green(' .codex/agents/') + chalk.gray(' - 8 native Toh agents'.padEnd(42)))); console.log(empty); } diff --git a/installer/uninstall.js b/installer/uninstall.js index 1817ded..951f7c5 100644 --- a/installer/uninstall.js +++ b/installer/uninstall.js @@ -37,8 +37,9 @@ import yaml from 'js-yaml'; import crypto from 'crypto'; import os from 'os'; import { fileURLToPath } from 'url'; -import { dirname, join, resolve, basename, parse as parsePath } from 'path'; +import { dirname, join, resolve, relative, basename, parse as parsePath } from 'path'; import { generateClaudeMd } from './ide-handlers/claude-code.js'; +import { uninstallCodex } from './ide-handlers/codex.js'; const __filename = fileURLToPath(import.meta.url); const __dirname = dirname(__filename); @@ -280,6 +281,9 @@ function derivedPaths(cat) { } for (const d of cat.agentsSkillDirs) add(`.agents/skills/${d}/SKILL.md`); for (const f of cat.agentsCommandFiles) add(`.agents/commands/${f}`); + // v2.2: native Codex agents + their ownership manifest + for (const n of cat.agentNames) add(`.codex/agents/${n.replace(/\.md$/, '')}.toml`); + add('.codex/toh-framework.json'); for (const f of cat.workflowFiles) { add(`.agents/workflows/${f}`); add(`.agent/workflows/${f}`); @@ -1336,6 +1340,9 @@ export async function uninstall(options = {}) { const assumeYes = !!options.yes; const includeUserWork = !!options.all; const verbose = !!options.verbose; + const requestedIdes = options.ide + ? String(options.ide).split(',').map((ide) => ide.trim().toLowerCase()).filter(Boolean) + : null; console.log(chalk.cyan(`\n🧹 Removing Toh Framework from:\n ${targetDir}\n`)); @@ -1355,6 +1362,60 @@ export async function uninstall(options = {}) { return 1; } + // --- per-IDE removal (v2.2: Codex only) ------------------------------------ + // `--ide codex` removes just the native agent files this installer wrote + // (hash-verified) plus their manifest. AGENTS.md and .codex/config.toml are + // shared surfaces (ZCode reads AGENTS.md too) and stay — run without --ide + // for the complete, previewed removal. + if (requestedIdes) { + const isCodex = (ide) => ide === 'codex' || ide === 'codex-cli'; + if (!requestedIdes.every(isCodex)) { + const unsupported = requestedIdes.filter((ide) => !isCodex(ide)); + console.log(chalk.yellow( + `Per-IDE removal currently supports Codex only (got: ${unsupported.join(', ')}).\n` + + `Run without --ide for a full uninstall, or use --ide codex.\n` + )); + return 1; + } + // Same manners as the full path: preview first, then one question. + const preview = await uninstallCodex(targetDir, { dryRun: true }); + const keptNote = preview.keptAgents.length + ? ` ${plural(preview.keptAgents.length, 'file', 'files')} you edited will stay: ${preview.keptAgents.join(', ')}.` + : ''; + const staysNote = 'AGENTS.md, .codex/config.toml and .toh/ stay (shared surfaces — run without --ide to remove everything).'; + if (preview.removedAgents.length === 0) { + console.log(chalk.yellow(`No Toh-written native agent files found in .codex/agents/ — nothing to remove.${keptNote}\n${staysNote}\n`)); + return 0; + } + console.log(chalk.white( + `I will remove ${plural(preview.removedAgents.length, 'native agent file', 'native agent files')} from .codex/agents/ ` + + `(${preview.removedAgents.join(', ')}) plus .codex/toh-framework.json.${keptNote}\n${staysNote}\n` + )); + if (dryRun) { + console.log(chalk.gray('This was a preview (--dry-run). Nothing was deleted or changed.\n')); + return 0; + } + if (!assumeYes) { + let go = false; + try { + ({ go } = await inquirer.prompt([{ type: 'confirm', name: 'go', message: 'Go ahead?', default: false }])); + } catch { + console.log(chalk.yellow('\nI could not read your answer, so I stopped and changed nothing.\n')); + return 1; + } + if (!go) { + console.log(chalk.yellow('\nStopped. Nothing was changed.\n')); + return 0; + } + } + const result = await uninstallCodex(targetDir, { dryRun: false, backup: options.backup !== false }); + console.log(chalk.green( + `Codex: removed ${plural(result.removedAgents.length, 'native agent file', 'native agent files')} from .codex/agents/.` + + (result.backupPath ? ` A copy was saved to ${relative(targetDir, result.backupPath)}/ first.` : '') + '\n' + )); + return 0; + } + // --- gather evidence ------------------------------------------------------ const cat = await buildCatalog(SRC_DIR); if (!(await detectInstall(targetDir, cat))) { diff --git a/package-lock.json b/package-lock.json index 05c5da5..4d16ecd 100644 --- a/package-lock.json +++ b/package-lock.json @@ -14,7 +14,8 @@ "fs-extra": "^11.2.0", "inquirer": "^9.2.23", "js-yaml": "^4.3.0", - "ora": "^8.0.1" + "ora": "^8.0.1", + "smol-toml": "^1.8.0" }, "bin": { "toh": "bin/toh-cli.js", @@ -789,6 +790,18 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/smol-toml": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/smol-toml/-/smol-toml-1.8.0.tgz", + "integrity": "sha512-kCZr2V3ch9i00x8zXRhjUNVcjG9ijES5dDudkXvUVCT5QlJNQWElSJdZqyPemffHoLNUYwOcou0Fy+ojN0uHSQ==", + "license": "BSD-3-Clause", + "engines": { + "node": ">= 18" + }, + "funding": { + "url": "https://github.com/sponsors/cyyynthia" + } + }, "node_modules/stdin-discarder": { "version": "0.2.2", "resolved": "https://registry.npmjs.org/stdin-discarder/-/stdin-discarder-0.2.2.tgz", diff --git a/package.json b/package.json index 91fba56..2278620 100644 --- a/package.json +++ b/package.json @@ -58,7 +58,8 @@ "uninstall:local": "node bin/toh-cli.js uninstall", "list": "node bin/toh-cli.js list", "status": "node bin/toh-cli.js status", - "bundle": "node bin/toh-cli.js bundle" + "bundle": "node bin/toh-cli.js bundle", + "test": "node tests/run.js" }, "engines": { "node": ">=18.0.0" @@ -69,6 +70,7 @@ "fs-extra": "^11.2.0", "inquirer": "^9.2.23", "js-yaml": "^4.3.0", - "ora": "^8.0.1" + "ora": "^8.0.1", + "smol-toml": "^1.8.0" } } diff --git a/src/agents/README.md b/src/agents/README.md index 6c6f80a..b3dfbb9 100644 --- a/src/agents/README.md +++ b/src/agents/README.md @@ -17,7 +17,8 @@ src/agents/*.md ← single source (superset frontmatter + canonical │ (uses native name / description / tools / model) ├── Cursor → native subagents → .cursor/agents/*.md ├── Antigravity → .agents/agents/*.md (subagent: true) - ├── Codex/ZCode → compact roster in AGENTS.md, bodies read from .toh/ + ├── Codex → compact roster in AGENTS.md + native agents in .codex/agents/*.toml + ├── ZCode → compact roster in AGENTS.md, bodies read from .toh/ └── Gemini (legacy) → convert frontmatter to the Gemini format ``` @@ -37,6 +38,7 @@ tools: # Claude Code: native tool allowlist - Edit - Bash model: sonnet # Claude Code: model tier per agent +modelIntent: implementation # Codex: lightweight | implementation | planning | review -> reasoning effort (model is inherited) skills: # Toh skill bindings (all IDEs) - ui-first-builder - design-craft @@ -80,7 +82,8 @@ The same source produces IDE-appropriate output at install time: | Claude Code | `.claude/agents/*.md` | Copied as-is (native `name`/`description`/`tools`/`model`) | | Cursor (2.4+) | `.cursor/agents/*.md` | Native subagents (`readonly` derived from the tools allowlist) | | Antigravity (+ CLI) | `.agents/agents/*.md` | Frontmatter converted, `subagent: true` | -| Codex (CLI + desktop app) / ZCode | `AGENTS.md` roster + `.toh/agents/*.md` | Compact roster table; bodies read at runtime | +| Codex (CLI + desktop app) | `AGENTS.md` roster + `.codex/agents/*.toml` | Compact roster table + one native agent per Toh agent (no `model` key; effort from `modelIntent`) | +| ZCode | `AGENTS.md` roster + `.toh/agents/*.md` | Compact roster table; bodies read at runtime | | Gemini CLI (legacy) | `.toh/agents/*.md` | Frontmatter converted per IDE | ``` diff --git a/src/agents/backend-connector.md b/src/agents/backend-connector.md index 105ea3a..e12000d 100644 --- a/src/agents/backend-connector.md +++ b/src/agents/backend-connector.md @@ -11,6 +11,7 @@ tools: - Edit - Bash model: sonnet +modelIntent: implementation skills: - backend-engineer # Core backend / Supabase skills - engineer-harness # Smart tool selection + human-friendly reporting + next steps diff --git a/src/agents/design-reviewer.md b/src/agents/design-reviewer.md index e31c4b6..776d352 100644 --- a/src/agents/design-reviewer.md +++ b/src/agents/design-reviewer.md @@ -13,6 +13,7 @@ tools: - Edit - Bash model: opus +modelIntent: review memory: project skills: - design-craft # Two-pass identity process + AVOID-LIST + usability floor diff --git a/src/agents/dev-builder.md b/src/agents/dev-builder.md index 241759d..cd2bafc 100644 --- a/src/agents/dev-builder.md +++ b/src/agents/dev-builder.md @@ -12,6 +12,7 @@ tools: - Bash - WebFetch model: sonnet +modelIntent: implementation isolation: worktree skills: - dev-engineer # Core dev skills diff --git a/src/agents/plan-orchestrator.md b/src/agents/plan-orchestrator.md index 0b7cd88..1cdcdc6 100644 --- a/src/agents/plan-orchestrator.md +++ b/src/agents/plan-orchestrator.md @@ -14,6 +14,7 @@ tools: - Bash - WebFetch model: opus +modelIntent: planning memory: project skills: - plan-orchestrator # Planning + plan artifact + single-gate handoff diff --git a/src/agents/platform-adapter.md b/src/agents/platform-adapter.md index fcf78c0..5afac65 100644 --- a/src/agents/platform-adapter.md +++ b/src/agents/platform-adapter.md @@ -12,6 +12,7 @@ tools: - Edit - Bash model: sonnet +modelIntent: implementation skills: - platform-specialist # Core platform adaptation skills (doc-driven) - engineer-harness # Human-friendly reporting + next steps diff --git a/src/agents/root-cause-debugger.md b/src/agents/root-cause-debugger.md index d4442ef..19025fc 100644 --- a/src/agents/root-cause-debugger.md +++ b/src/agents/root-cause-debugger.md @@ -11,6 +11,7 @@ tools: - Glob - Bash model: sonnet +modelIntent: review skills: - debug-protocol - error-handling diff --git a/src/agents/test-runner.md b/src/agents/test-runner.md index eb5de1b..3829c92 100644 --- a/src/agents/test-runner.md +++ b/src/agents/test-runner.md @@ -11,6 +11,7 @@ tools: - Edit - Bash model: haiku +modelIntent: lightweight maxTurns: 30 skills: - test-engineer # Core testing skills diff --git a/src/agents/ui-builder.md b/src/agents/ui-builder.md index 2f83c57..3b7fb70 100644 --- a/src/agents/ui-builder.md +++ b/src/agents/ui-builder.md @@ -11,6 +11,7 @@ tools: - Edit - Bash model: sonnet +modelIntent: implementation isolation: worktree skills: - ui-first-builder # Core UI building methodology diff --git a/tests/codex.test.js b/tests/codex.test.js new file mode 100644 index 0000000..b3ada55 --- /dev/null +++ b/tests/codex.test.js @@ -0,0 +1,415 @@ +/** + * Codex integration tests (node:test) — native agents in .codex/agents/*.toml. + * + * Every check below is an observable outcome of a real `install()` run into a + * temp directory, never a re-statement of the generator's own constants. + * + * Covers: + * - fresh install layout (.toh/, shared .agents/skills/, .codex/agents/, AGENTS.md) + * - agent TOML shape: no `model` key (inherits the session), reasoning effort + * from modelIntent, read-only sandbox for read-only agents + * - single writer for .agents/skills/ — same wrappers whatever the IDE order + * - a user's existing .codex/config.toml is never modified + * - ownership by hash: edited or user-created agent files are never touched + * - AGENTS.md: user content preserved, reinstall idempotent, block under budget + * - `uninstall --ide codex` (native agents only) and the full uninstall + * - the codex capability profile stays the conservative, probe-upgraded floor + * + * Run: npm test + */ + +// Silence ora spinners: ora's stream writes corrupt node:test's child-process +// IPC channel on Node 24. Read at call time, so import hoisting is harmless. +process.env.TOH_QUIET = '1'; + +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { createHash } from 'node:crypto'; +import fs from 'fs-extra'; +import os from 'os'; +import path from 'path'; +import { fileURLToPath } from 'url'; +import yaml from 'js-yaml'; +import { parse as parseToml } from 'smol-toml'; + +import { install } from '../installer/install.js'; +import { uninstall } from '../installer/uninstall.js'; +import { + CODEX_AGENTS_DIR, + CODEX_MANIFEST_PATH, + CODEX_REASONING_EFFORT, + installCodexAgents, + readAgentCatalog, + resolveCodexModelIntent, + translateAgentToCodex, + uninstallCodex, + writeAgentsMd +} from '../installer/ide-handlers/codex.js'; +import { CAPABILITY_PROFILES } from '../installer/ide-handlers/shared.js'; + +const __filename = fileURLToPath(import.meta.url); +const REPO_ROOT = path.join(path.dirname(__filename), '..'); +const SRC_DIR = path.join(REPO_ROOT, 'src'); + +const EXPECTED_COMMANDS = [ + 'toh', 'toh-connect', 'toh-design', 'toh-dev', 'toh-fix', 'toh-help', 'toh-line', + 'toh-mobile', 'toh-plan', 'toh-protect', 'toh-ship', 'toh-test', 'toh-ui', 'toh-vibe' +]; + +const EXPECTED_AGENTS = [ + 'backend-connector', 'design-reviewer', 'dev-builder', 'plan-orchestrator', + 'platform-adapter', 'root-cause-debugger', 'test-runner', 'ui-builder' +]; + +// ---------------------------------------------------------------- helpers + +async function makeTmpProject() { + return fs.mkdtemp(path.join(os.tmpdir(), 'toh-codex-test-')); +} + +/** install() with quick: true is fully non-interactive, even on reinstall. */ +async function quickInstall(targetDir, ide = 'codex') { + await install({ target: targetDir, ide, quick: true }); +} + +function sha256(buf) { + return createHash('sha256').update(buf).digest('hex'); +} + +/** Map of relative path -> content for every file under root. */ +async function snapshotTree(root) { + const out = new Map(); + const walk = async (dir) => { + for (const entry of await fs.readdir(dir, { withFileTypes: true })) { + const p = path.join(dir, entry.name); + if (entry.isDirectory()) await walk(p); + else out.set(path.relative(root, p), await fs.readFile(p, 'utf8')); + } + }; + if (await fs.pathExists(root)) await walk(root); + return out; +} + +function frontmatterOf(raw) { + const m = raw.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n?([\s\S]*)$/); + assert.ok(m, 'file must start with a YAML frontmatter block'); + return { fm: yaml.load(m[1]) || {}, body: m[2] }; +} + +async function readAgentToml(dir, name) { + const raw = await fs.readFile(path.join(dir, CODEX_AGENTS_DIR, `${name}.toml`), 'utf8'); + return { raw, parsed: parseToml(raw) }; +} + +// ---------------------------------------------------------------- tests + +test('fresh Codex install creates .toh/, shared .agents/skills/, .codex/agents/ and AGENTS.md', async () => { + const dir = await makeTmpProject(); + try { + await quickInstall(dir); + + assert.ok(await fs.pathExists(path.join(dir, '.toh', 'plan.md'))); + assert.ok(await fs.pathExists(path.join(dir, '.toh', 'memory', 'active.md'))); + + // The shared writer owns .agents/skills/: 23 framework skills + 14 command skills. + const skillDirs = (await fs.readdir(path.join(dir, '.agents', 'skills'))).sort(); + assert.equal(skillDirs.length, 37); + for (const cmd of EXPECTED_COMMANDS) assert.ok(skillDirs.includes(cmd), `missing ${cmd}`); + + // One native Codex agent per Toh agent, plus the ownership manifest. + const tomls = (await fs.readdir(path.join(dir, CODEX_AGENTS_DIR))).sort(); + assert.deepEqual(tomls, EXPECTED_AGENTS.map((n) => `${n}.toml`)); + const manifest = await fs.readJson(path.join(dir, CODEX_MANIFEST_PATH)); + assert.equal(manifest.generator, 'toh-framework'); + assert.equal(Object.keys(manifest.agents).length, EXPECTED_AGENTS.length); + + const agentsMd = await fs.readFile(path.join(dir, 'AGENTS.md'), 'utf8'); + assert.match(agentsMd, //); + assert.match(agentsMd, /\.codex\/agents\/\*\.toml/); + assert.match(agentsMd, /\$toh-/); + + // project-doc quota is written only because no config.toml existed. + const config = await fs.readFile(path.join(dir, '.codex', 'config.toml'), 'utf8'); + assert.equal(parseToml(config).project_doc_max_bytes, 131072); + assert.doesNotMatch(config, /\[features\]/); + } finally { + await fs.remove(dir); + } +}); + +test('agent TOML: valid, no model key, effort from intent, sandbox from tool allowlist', async () => { + const dir = await makeTmpProject(); + try { + await quickInstall(dir); + const agents = await readAgentCatalog(dir); + assert.equal(agents.length, EXPECTED_AGENTS.length); + + for (const agent of agents) { + const { raw, parsed } = await readAgentToml(dir, agent.name); + assert.equal(parsed.name, agent.name); + assert.ok(parsed.description.length > 0); + assert.ok(parsed.developer_instructions.length > 100, `${agent.name}: instructions look empty`); + // The model is inherited from the parent session — never pinned here. + assert.equal(Object.hasOwn(parsed, 'model'), false, `${agent.name}: must not pin a model`); + assert.doesNotMatch(raw, /^model\s*=/m); + assert.equal(parsed.model_reasoning_effort, CODEX_REASONING_EFFORT[agent.modelIntent]); + assert.ok(['read-only', 'workspace-write'].includes(parsed.sandbox_mode)); + } + + // Read-only allowlist (Read/Grep/Glob/Bash) -> read-only sandbox; builders write. + assert.equal((await readAgentToml(dir, 'root-cause-debugger')).parsed.sandbox_mode, 'read-only'); + assert.equal((await readAgentToml(dir, 'ui-builder')).parsed.sandbox_mode, 'workspace-write'); + + // Intents declared in src/agents/*.md land where expected. + assert.equal((await readAgentToml(dir, 'plan-orchestrator')).parsed.model_reasoning_effort, 'high'); + assert.equal((await readAgentToml(dir, 'test-runner')).parsed.model_reasoning_effort, 'low'); + assert.equal((await readAgentToml(dir, 'dev-builder')).parsed.model_reasoning_effort, 'medium'); + } finally { + await fs.remove(dir); + } +}); + +test('modelIntent resolution: explicit key wins, Claude tier is the fallback', () => { + assert.equal(resolveCodexModelIntent({ modelIntent: 'review', model: 'haiku' }), 'review'); + assert.equal(resolveCodexModelIntent({ modelIntent: 'deep_reasoning' }), 'planning'); + assert.equal(resolveCodexModelIntent({ modelIntent: 'scaffold' }), 'lightweight'); + assert.equal(resolveCodexModelIntent({ model: 'haiku' }), 'lightweight'); + assert.equal(resolveCodexModelIntent({ model: 'opus' }), 'planning'); + assert.equal(resolveCodexModelIntent({ model: 'sonnet' }), 'implementation'); + assert.equal(resolveCodexModelIntent({}), 'implementation'); + assert.equal(resolveCodexModelIntent({ modelIntent: 'nonsense', model: 'opus' }), 'planning'); +}); + +test('translateAgentToCodex escapes arbitrary bodies into valid TOML', () => { + const toml = translateAgentToCodex({ + name: 'edge-case', + description: 'Quotes "here", backslash \\ and a tab\t.', + body: 'Line one\n\nLine "two" with `code` and \\ backslashes\n# not a toml comment', + tools: ['Read', 'Write'], + skills: ['design-craft'], + triggers: ['ui', 'design'], + modelIntent: 'implementation', + maxTurns: 12 + }); + const parsed = parseToml(toml); + assert.equal(parsed.name, 'edge-case'); + assert.equal(parsed.sandbox_mode, 'workspace-write'); + assert.match(parsed.developer_instructions, /Line "two" with `code`/); + assert.match(parsed.developer_instructions, /Source turn budget hint: 12\./); + assert.match(parsed.developer_instructions, /\.toh\/skills\/design-craft\/SKILL\.md/); +}); + +test('single writer: .agents/skills wrappers are identical regardless of IDE order', async () => { + const a = await makeTmpProject(); + const b = await makeTmpProject(); + try { + await quickInstall(a, 'codex,cursor'); // Codex first + await quickInstall(b, 'cursor'); // Cursor first… + await quickInstall(b, 'codex'); // …Codex added later + + const skillsA = await snapshotTree(path.join(a, '.agents', 'skills')); + const skillsB = await snapshotTree(path.join(b, '.agents', 'skills')); + assert.equal(skillsA.size, 37); + assert.deepEqual([...skillsA.keys()].sort(), [...skillsB.keys()].sort()); + for (const [rel, content] of skillsA) { + assert.equal(skillsB.get(rel), content, `${rel} differs by install order`); + } + + // Wrappers are the shared, runtime-neutral ones — nothing Codex-specific + // leaks into what Cursor / Antigravity / ZCode read. + const vibe = skillsA.get(path.join('toh-vibe', 'SKILL.md')); + assert.doesNotMatch(vibe, /\.codex\/agents/); + assert.doesNotMatch(vibe, /Codex has no Toh Stop hook/); + const { fm } = frontmatterOf(vibe); + assert.equal(fm.name, 'toh-vibe'); + assert.ok(fm.description); + + // Codex got all 8 native agents in both orders. + for (const dir of [a, b]) { + const tomls = await fs.readdir(path.join(dir, CODEX_AGENTS_DIR)); + assert.equal(tomls.length, EXPECTED_AGENTS.length, `${dir}: expected 8 native agents`); + } + } finally { + await fs.remove(a); + await fs.remove(b); + } +}); + +test('an existing user .codex/config.toml is never modified', async () => { + const dir = await makeTmpProject(); + try { + const configPath = path.join(dir, '.codex', 'config.toml'); + const userConfig = 'model = "gpt-5.6-sol"\napproval_policy = "on-request"\n\n[mcp_servers.github]\ncommand = "gh-mcp"\n'; + await fs.outputFile(configPath, userConfig); + const before = sha256(await fs.readFile(configPath)); + + await quickInstall(dir); + await quickInstall(dir); // and again, on reinstall + + assert.equal(sha256(await fs.readFile(configPath)), before, 'user config.toml was rewritten'); + assert.equal(await fs.readFile(configPath, 'utf8'), userConfig); + } finally { + await fs.remove(dir); + } +}); + +test('ownership by hash: edited and user-created agent files are never touched', async () => { + const dir = await makeTmpProject(); + try { + await quickInstall(dir); + + // User edits one generated agent and adds their own. + const edited = path.join(dir, CODEX_AGENTS_DIR, 'ui-builder.toml'); + const editedContent = (await fs.readFile(edited, 'utf8')) + '\n# my tweak\n'; + await fs.writeFile(edited, editedContent); + const own = path.join(dir, CODEX_AGENTS_DIR, 'my-reviewer.toml'); + const ownContent = 'name = "my-reviewer"\ndescription = "mine"\ndeveloper_instructions = "keep"\n'; + await fs.writeFile(own, ownContent); + + const result = await installCodexAgents(dir); + assert.deepEqual(result.kept, ['ui-builder']); + assert.equal(result.installed.length, EXPECTED_AGENTS.length - 1); + assert.equal(await fs.readFile(edited, 'utf8'), editedContent, 'edited agent was overwritten'); + assert.equal(await fs.readFile(own, 'utf8'), ownContent, 'user agent was touched'); + + // The manifest still remembers the original hash for the edited file, so a + // later uninstall knows it is no longer ours. + const manifest = await fs.readJson(path.join(dir, CODEX_MANIFEST_PATH)); + assert.ok(manifest.agents['.codex/agents/ui-builder.toml']); + assert.equal(manifest.agents['.codex/agents/my-reviewer.toml'], undefined); + } finally { + await fs.remove(dir); + } +}); + +test('stale agent files we wrote are removed on reinstall only when untouched', async () => { + const dir = await makeTmpProject(); + try { + await quickInstall(dir); + const manifestPath = path.join(dir, CODEX_MANIFEST_PATH); + const manifest = await fs.readJson(manifestPath); + + // Simulate an agent that existed in an older release: write it and record it. + const staleOurs = 'name = "old-agent"\ndescription = "gone upstream"\ndeveloper_instructions = "x"\n'; + await fs.writeFile(path.join(dir, CODEX_AGENTS_DIR, 'old-agent.toml'), staleOurs); + manifest.agents['.codex/agents/old-agent.toml'] = { sha256: sha256(staleOurs), source: '.toh/agents/old-agent.md' }; + const staleEdited = 'name = "old-edited"\ndescription = "edited"\ndeveloper_instructions = "y"\n'; + await fs.writeFile(path.join(dir, CODEX_AGENTS_DIR, 'old-edited.toml'), staleEdited + '# changed\n'); + manifest.agents['.codex/agents/old-edited.toml'] = { sha256: sha256(staleEdited), source: '.toh/agents/old-edited.md' }; + await fs.writeJson(manifestPath, manifest, { spaces: 2 }); + + await installCodexAgents(dir); + assert.equal(await fs.pathExists(path.join(dir, CODEX_AGENTS_DIR, 'old-agent.toml')), false, 'untouched stale file should go'); + assert.equal(await fs.pathExists(path.join(dir, CODEX_AGENTS_DIR, 'old-edited.toml')), true, 'edited stale file must stay'); + } finally { + await fs.remove(dir); + } +}); + +test('AGENTS.md: user content preserved, reinstall idempotent, block under budget', async () => { + const dir = await makeTmpProject(); + try { + const userText = '# My project\n\nKeep this paragraph.\n'; + await fs.writeFile(path.join(dir, 'AGENTS.md'), userText); + + await quickInstall(dir); + const first = await fs.readFile(path.join(dir, 'AGENTS.md'), 'utf8'); + assert.ok(first.startsWith(userText), 'user text must stay at the top'); + + await quickInstall(dir); + const second = await fs.readFile(path.join(dir, 'AGENTS.md'), 'utf8'); + assert.equal(second, first, 'reinstall must be byte-identical'); + assert.equal(second.split('').length - 1, 1); + assert.equal(second.split('').length - 1, 1); + + // The generator reports the block size; Codex truncates at 32 KiB combined, + // so the block must stay under the 24 KiB hard budget. + const bytes = await writeAgentsMd(dir, SRC_DIR, 'en', 'codex'); + assert.ok(bytes > 4000 && bytes < 24 * 1024, `block is ${bytes} bytes`); + + // Native agent tree is identical across reinstalls too. + const tree1 = await snapshotTree(path.join(dir, CODEX_AGENTS_DIR)); + await quickInstall(dir); + const tree2 = await snapshotTree(path.join(dir, CODEX_AGENTS_DIR)); + assert.deepEqual([...tree1.entries()], [...tree2.entries()]); + } finally { + await fs.remove(dir); + } +}); + +test('uninstall --ide codex removes only our native agent files; shared surfaces stay', async () => { + const dir = await makeTmpProject(); + try { + await quickInstall(dir); + const edited = path.join(dir, CODEX_AGENTS_DIR, 'ui-builder.toml'); + await fs.appendFile(edited, '\n# my tweak\n'); + const agentsMdBefore = await fs.readFile(path.join(dir, 'AGENTS.md'), 'utf8'); + const configBefore = await fs.readFile(path.join(dir, '.codex', 'config.toml'), 'utf8'); + + const preview = await uninstallCodex(dir, { dryRun: true }); + assert.equal(preview.removedAgents.length, EXPECTED_AGENTS.length - 1); + assert.deepEqual(preview.keptAgents, ['ui-builder']); + assert.ok(await fs.pathExists(path.join(dir, CODEX_AGENTS_DIR, 'dev-builder.toml')), 'dry-run must not delete'); + + const code = await uninstall({ target: dir, ide: 'codex', yes: true }); + assert.equal(code, 0); + assert.equal(await fs.pathExists(path.join(dir, CODEX_AGENTS_DIR, 'dev-builder.toml')), false); + assert.equal(await fs.pathExists(edited), true, 'edited agent must survive'); + assert.equal(await fs.pathExists(path.join(dir, CODEX_MANIFEST_PATH)), false); + assert.equal(await fs.readFile(path.join(dir, 'AGENTS.md'), 'utf8'), agentsMdBefore); + assert.equal(await fs.readFile(path.join(dir, '.codex', 'config.toml'), 'utf8'), configBefore); + assert.ok(await fs.pathExists(path.join(dir, '.toh', 'plan.md'))); + + // A backup copy of what was removed exists. + const backups = await fs.readdir(path.join(dir, '.toh-uninstall-backup')); + assert.ok(backups.some((n) => n.startsWith('codex-agents-'))); + } finally { + await fs.remove(dir); + } +}); + +test('full uninstall removes .codex/agents/ and the manifest, keeps the plan', async () => { + const dir = await makeTmpProject(); + try { + await quickInstall(dir); + const code = await uninstall({ target: dir, yes: true }); + assert.equal(code, 0); + assert.equal(await fs.pathExists(path.join(dir, CODEX_AGENTS_DIR)), false); + assert.equal(await fs.pathExists(path.join(dir, CODEX_MANIFEST_PATH)), false); + assert.equal(await fs.pathExists(path.join(dir, 'AGENTS.md')), false, 'TOH-only AGENTS.md should go'); + assert.ok(await fs.pathExists(path.join(dir, '.toh', 'plan.md')), 'the plan is the user\'s work'); + } finally { + await fs.remove(dir); + } +}); + +test('codex capability profile stays the conservative floor (probe may upgrade it)', () => { + const codex = CAPABILITY_PROFILES.codex; + assert.equal(codex.subagents, 'none'); + assert.equal(codex.parallel, false); + assert.equal(codex.modelRouting, false); +}); + +test('Claude Code agents keep native fields and drop the Codex-only modelIntent', async () => { + const dir = await makeTmpProject(); + try { + await quickInstall(dir, 'claude'); + const raw = await fs.readFile(path.join(dir, '.claude', 'agents', 'ui-builder.md'), 'utf8'); + const { fm } = frontmatterOf(raw); + assert.equal(fm.name, 'ui-builder'); + assert.equal(fm.model, 'sonnet'); + assert.equal(fm.modelIntent, undefined); + assert.equal(await fs.pathExists(path.join(dir, CODEX_AGENTS_DIR)), false, 'claude-only install writes no Codex agents'); + } finally { + await fs.remove(dir); + } +}); + +test('readAgentCatalog returns [] for a project without .toh/agents', async () => { + const dir = await makeTmpProject(); + try { + assert.deepEqual(await readAgentCatalog(dir), []); + } finally { + await fs.remove(dir); + } +}); diff --git a/tests/run.js b/tests/run.js new file mode 100644 index 0000000..eb20b1e --- /dev/null +++ b/tests/run.js @@ -0,0 +1,13 @@ +/** + * In-band test runner. + * + * Why not `node --test tests/`? Node's default test isolation spawns each + * test file as a child process that reports over an IPC channel. On Node 24 + * that channel is intermittently corrupted by this repo's dependency stack + * ("Unable to deserialize cloned data due to invalid or unsupported + * version") even though every test passes when run in-band. Importing the + * test files here runs node:test in-process — deterministic on Node >= 18, + * and the process exit code still reflects failures. + */ + +import './codex.test.js';