From 8e6a0abe6b7dc3850b9f1e0871418fdff8de8339 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Sat, 25 Apr 2026 01:13:13 +0200 Subject: [PATCH] feat(cli): add Codex CLI as a selectable AI tool MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces ProjectConfig.claudeCode boolean with aiTool enum (claude / codex / none). Adds a CodexGenerator emitting AGENTS.md, .codex/config.toml, and .codex/rules/{backend,frontend}.md. The speckit generator now forwards --ai claude or --ai codex based on the choice and is skipped when aiTool is none. - New isCodexInstalled() helper mirrors the Claude detection (sync, --help) - --ai-tool CLI flag replaces --claude-code/--no-claude-code - forgekit add gains a "codex" layer alongside "claude-code" - Codex generator owns only its own dir (Constitution §1) and writes the four files in parallel (Constitution §10) Tests: new vitest suite for the Codex generator (9 cases), new isCodexInstalled suite, expanded speckit suite covering all three aiTool values. Every existing fixture migrated to declare aiTool explicitly (Constitution §7). Spec at specs/016-codex-ai-tool/. --- eslint.config.js | 2 +- .../checklists/requirements.md | 34 +++++ specs/016-codex-ai-tool/plan.md | 115 ++++++++++++++ specs/016-codex-ai-tool/qa-summary.md | 28 ++++ specs/016-codex-ai-tool/spec.md | 112 ++++++++++++++ specs/016-codex-ai-tool/tasks.md | 78 ++++++++++ src/__tests__/e2e.test.ts | 4 +- src/__tests__/fixtures.ts | 2 +- src/commands/add.ts | 34 ++++- src/commands/new.ts | 31 +++- src/generators/__tests__/speckit.test.ts | 28 +++- .../claude-code/__tests__/claude-code.test.ts | 2 +- src/generators/codex/__tests__/codex.test.ts | 111 ++++++++++++++ src/generators/codex/index.ts | 95 ++++++++++++ src/generators/speckit.ts | 6 +- src/index.ts | 2 +- src/prompts/project.ts | 55 +++++-- src/templates/codex/AGENTS.md.hbs | 142 ++++++++++++++++++ src/templates/codex/config.toml.hbs | 13 ++ src/templates/codex/rules/backend.md.hbs | 45 ++++++ src/templates/codex/rules/frontend.md.hbs | 27 ++++ src/types.ts | 3 +- src/utils/__tests__/system.test.ts | 30 +++- src/utils/detect-project.ts | 12 +- src/utils/system.ts | 7 + 25 files changed, 970 insertions(+), 48 deletions(-) create mode 100644 specs/016-codex-ai-tool/checklists/requirements.md create mode 100644 specs/016-codex-ai-tool/plan.md create mode 100644 specs/016-codex-ai-tool/qa-summary.md create mode 100644 specs/016-codex-ai-tool/spec.md create mode 100644 specs/016-codex-ai-tool/tasks.md create mode 100644 src/generators/codex/__tests__/codex.test.ts create mode 100644 src/generators/codex/index.ts create mode 100644 src/templates/codex/AGENTS.md.hbs create mode 100644 src/templates/codex/config.toml.hbs create mode 100644 src/templates/codex/rules/backend.md.hbs create mode 100644 src/templates/codex/rules/frontend.md.hbs diff --git a/eslint.config.js b/eslint.config.js index ac5a7f7..252c3fd 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -21,6 +21,6 @@ export default tseslint.config( }, }, { - ignores: ["dist/", "node_modules/"], + ignores: ["dist/", "node_modules/", "coverage/"], }, ); diff --git a/specs/016-codex-ai-tool/checklists/requirements.md b/specs/016-codex-ai-tool/checklists/requirements.md new file mode 100644 index 0000000..ae5ec54 --- /dev/null +++ b/specs/016-codex-ai-tool/checklists/requirements.md @@ -0,0 +1,34 @@ +# Specification Quality Checklist: Codex CLI as a Selectable AI Tool + +**Purpose**: Validate specification completeness and quality before proceeding to planning +**Created**: 2026-04-24 +**Feature**: [spec.md](../spec.md) + +## Content Quality + +- [x] No implementation details (languages, frameworks, APIs) — only file-shape constraints, no language references in user stories +- [x] Focused on user value and business needs +- [x] Written for non-technical stakeholders — file names appear because they are the user-facing contract of a scaffolder +- [x] All mandatory sections completed + +## Requirement Completeness + +- [x] No [NEEDS CLARIFICATION] markers remain +- [x] Requirements are testable and unambiguous +- [x] Success criteria are measurable +- [x] Success criteria are technology-agnostic — no perf numbers, only count + leakage assertions +- [x] All acceptance scenarios are defined +- [x] Edge cases are identified +- [x] Scope is clearly bounded (Out of Scope section) +- [x] Dependencies and assumptions identified + +## Feature Readiness + +- [x] All functional requirements have clear acceptance criteria +- [x] User scenarios cover primary flows (Claude / Codex / None) +- [x] Feature meets measurable outcomes defined in Success Criteria +- [x] No implementation details leak into specification + +## Notes + +This is a CLI-scaffolder feature, so artifact filenames (`CLAUDE.md`, `AGENTS.md`, `.codex/config.toml`, etc.) appear in functional requirements — they are the **user-visible contract** of the tool, not implementation details. Treat them as part of the spec. diff --git a/specs/016-codex-ai-tool/plan.md b/specs/016-codex-ai-tool/plan.md new file mode 100644 index 0000000..6f59aa9 --- /dev/null +++ b/specs/016-codex-ai-tool/plan.md @@ -0,0 +1,115 @@ +# Implementation Plan: Codex CLI as a Selectable AI Tool + +**Branch**: `016-codex-ai-tool` | **Date**: 2026-04-24 | **Spec**: [spec.md](./spec.md) +**Input**: Feature specification from `/specs/016-codex-ai-tool/spec.md` + +## Summary + +Replace the existing `claudeCode: boolean` flag in `ProjectConfig` with `aiTool: 'claude' | 'codex' | 'none'`, branch the generator dispatcher on it, add a new `CodexGenerator` that emits `AGENTS.md` + `.codex/config.toml` + `.codex/rules/{backend,frontend}.md`, and parameterize the existing `speckit` generator so `specify init` receives the correct `--ai` flag for each tool. Skip the `speckitPreset` prompt for Codex. + +## Technical Context + +**Language/Version**: TypeScript 5.9 / Node.js ≥20 +**Primary Dependencies**: Commander 14, Inquirer 8, Handlebars 4 (existing — no new deps) +**Storage**: N/A (CLI scaffolder; reads/writes filesystem only) +**Testing**: Vitest 4 + @vitest/coverage-v8 (existing) +**Target Platform**: Local developer machines (macOS, Linux, Windows via Node) +**Project Type**: CLI tool (single project, single module tree) +**Performance Goals**: N/A (one-shot scaffolding; existing constraints apply: parallel I/O per Constitution §10) +**Constraints**: Must follow ForgeKit Constitution (single layer per generator, fail-fast rollback, no speculative abstractions) +**Scale/Scope**: ~5 modified files + ~5 new files; ~3 new vitest suites or expanded suites + +## Constitution Check + +*GATE: Must pass before implementation. Re-check after Phase 1 design.* + +| Constitution Rule | Compliance | +|---|---| +| §1 — One layer per generator | ✅ `CodexGenerator` writes only `AGENTS.md` + `.codex/`. `ClaudeCodeGenerator` unchanged. `speckit.ts` continues owning `.specify/`. | +| §2 — Templates contain zero logic | ✅ All branching (per-stack rules, presence of MCP) lives in `CodexGenerator`. Handlebars templates receive a flat data object. | +| §3 — ProjectConfig is single source of truth | ✅ `aiTool` flows top-down. No filesystem probing inside generators. | +| §4 — Fail fast, rollback completely | ✅ Existing rollback in `commands/new.ts` already wraps generators. Codex generator integrates into the same try/catch. | +| §5 — Network failures silent | ✅ No new network calls. Codex generator has no version-fetch path. | +| §6 — No speculative abstractions | ✅ Per-stack rule rendering has only 2 callsites (Claude, Codex). No shared module extracted. | +| §7 — Tests declare all fixture fields | ✅ All Claude tests will be updated to swap `claudeCode: true` → `aiTool: 'claude'`. New Codex tests follow same rule. | +| §8 — CLI detection synchronous + early | ✅ New `isCodexInstalled()` mirrors `isClaudeInstalled()`: `spawnSync` with `stdio: 'ignore'` and `--help`. | +| §9 — Release only via pipeline | ✅ No release-script changes. | +| §10 — I/O parallelized | ✅ `AGENTS.md`, `.codex/config.toml`, `.codex/rules/backend.md`, `.codex/rules/frontend.md` are independent → `Promise.all`. | + +**No violations. Complexity Tracking section omitted.** + +## Project Structure + +### Documentation (this feature) + +```text +specs/016-codex-ai-tool/ +├── plan.md # This file +├── spec.md # Feature spec +├── qa-summary.md # Confirmed scope from Q&A +├── checklists/ +│ └── requirements.md # Spec-quality checklist +└── tasks.md # Phase 2 output (sk:tasks) +``` + +`research.md`, `data-model.md`, and `contracts/` are intentionally **not generated** (plan-detail=low + no genuine technical unknowns + no entity model + no external wire contract). + +### Source Code (repository root) + +```text +src/ +├── types.ts [MODIFY: add AITool, swap claudeCode → aiTool] +├── prompts/ +│ └── project.ts [MODIFY: list-prompt for aiTool, conditional preset] +├── utils/ +│ └── (cli-detect helper file) [MODIFY: add isCodexInstalled] +├── commands/ +│ └── new.ts [MODIFY: branch on aiTool] +├── generators/ +│ ├── claude-code/ +│ │ ├── index.ts [MODIFY: gate on aiTool === 'claude' externally] +│ │ └── __tests__/claude-code.test.ts [MODIFY: fixtures use aiTool: 'claude'] +│ ├── codex/ +│ │ ├── index.ts [NEW: CodexGenerator] +│ │ └── __tests__/codex.test.ts [NEW: vitest suite] +│ └── speckit.ts [MODIFY: forward --ai based on aiTool] +└── templates/ + └── codex/ + ├── AGENTS.md.hbs [NEW] + ├── config.toml.hbs [NEW] + └── rules/ + ├── backend.md.hbs [NEW] + └── frontend.md.hbs [NEW] +``` + +**Structure Decision**: Single Node project, existing `src/` layout. New work confined to `src/generators/codex/` and `src/templates/codex/`. No top-level reshuffling. + +## Implementation Phases (high level) + +Following `cfg`: `tdd=false, verification=minimal, code-review=false, security-review=auto, subagents=false, fast-mode=true`. Per-task loop: write impl → write tests → `npm run lint && npm run typecheck`. Full `npm test` runs once in Phase 3. + +### Phase 2 — Direct implementation (per-task loop, no subagents) + +1. **Type migration** — `src/types.ts`: add `AITool`, replace `claudeCode` with `aiTool`. Mechanical rename across the codebase. +2. **CLI detection** — locate `isClaudeInstalled` (likely in `src/utils/`), add sibling `isCodexInstalled`. Same shape: `spawnSync('codex', ['--help'], { stdio: 'ignore' })`. +3. **Prompt rewiring** — `src/prompts/project.ts`: replace the Claude checkbox with a single `list` question for `aiTool`. Re-gate `workflowMode` on `aiTool !== 'none'` and `speckitPreset` on `aiTool === 'claude' && workflowMode === 'speckit'`. +4. **Dispatch update** — `src/commands/new.ts`: replace `if (config.claudeCode)` with `if (config.aiTool === 'claude')` and add an `else if (config.aiTool === 'codex')` branch invoking the new generator. +5. **Speckit generator** — `src/generators/speckit.ts`: forward `--ai claude` or `--ai codex` based on `config.aiTool`. Skip when `aiTool === 'none'`. +6. **Codex generator** — `src/generators/codex/index.ts`: new class extending `BaseGenerator`. Renders the four templates via `Promise.all`. +7. **Codex templates** — write the four `.hbs` files. Embed per-stack rule text in `AGENTS.md.hbs` since Codex does not read sub-files reliably. Keep `.codex/rules/*.md` for human/IDE convention. +8. **Update Claude tests** — swap `claudeCode: true` → `aiTool: 'claude'` everywhere. Add a "no Claude artifacts when aiTool !== 'claude'" case. +9. **Codex tests** — new suite asserting file presence per stack, content shape, no Claude leakage, and proper `--ai codex` forwarding to speckit. +10. **Smoke build** — `npm run build && npm run lint && npm run typecheck`. + +### Phase 3 — Verification & ship + +- Run full `npm test` once. Must be green. +- Security review (auto): touches no auth/input/secrets/external APIs → **skip** per cfg. +- Code review (cfg=false) → skip. +- `commit-commands:commit-push-pr` with title `feat(cli): add Codex CLI as AI tool option`. + +## Risks / Unknowns (still open) + +- The exact module path of `isClaudeInstalled` was not pinned in research; resolve at impl time via `grep -r "isClaudeInstalled" src/`. +- `BaseGenerator` is reportedly minimal (constructor + abstract `generate()`); confirm the constructor signature before subclassing. +- Old preset/JSON config files (if any exist in the wild) carrying `claudeCode: true` will break loudly. The spec accepts this. diff --git a/specs/016-codex-ai-tool/qa-summary.md b/specs/016-codex-ai-tool/qa-summary.md new file mode 100644 index 0000000..b6c85f0 --- /dev/null +++ b/specs/016-codex-ai-tool/qa-summary.md @@ -0,0 +1,28 @@ +# Q&A Summary — Codex CLI as AI tool option + +Confirmed by user before spec generation: + +## 1. ProjectConfig field shape +**Decision:** Replace `claudeCode: boolean` with an enum `aiTool: 'claude' | 'codex' | 'none'`. +- Hard rename — no compat shim, no parallel boolean. +- `AITool` type added to `src/types.ts`. + +## 2. Codex generator scope +**Decision:** Generate three artifacts when `aiTool === 'codex'`: +- `AGENTS.md` at project root — prose instructions with stack-specific conventions embedded inline (since Codex's `project_doc` discovery only reads `AGENTS.md` itself, not subfiles). +- `.codex/config.toml` — minimal TOML with `sandbox_mode`, `approval_policy`, optional MCP block. +- `.codex/rules/{backend,frontend}.md` — explicit user choice: keep a `rules/` directory as a human/IDE convention, even though Codex CLI does not natively read it. Risk acknowledged. + +**Not generated** for Codex: hooks, skills, slash commands, hookify files, `.claude/settings.json` equivalent — none of these exist as concepts in Codex CLI. + +## 3. Speckit + Codex +**Decision:** `specify init --ai codex --no-git` runs the same way as for Claude. Constitution template (`.specify/memory/constitution.md`) is generated identically. **The `speckitPreset` prompt is skipped** when `aiTool === 'codex'` because presets translate into a `.claude/settings.json` speckit block, which Codex cannot read. + +## 4. "None" option +**Decision:** Keep `aiTool: 'none'` as a valid choice, preserving the current `claudeCode: false` behavior (no AI tooling files emitted at all). + +## Out of scope +- No backward-compat shim for the renamed field. +- No support for additional AI tools (Cursor, Gemini, etc.) in this iteration. +- No refactor of the BaseGenerator pattern. +- No extraction of shared rules-rendering module — only 2 callsites (Claude + Codex), per Constitution §6. diff --git a/specs/016-codex-ai-tool/spec.md b/specs/016-codex-ai-tool/spec.md new file mode 100644 index 0000000..ced4033 --- /dev/null +++ b/specs/016-codex-ai-tool/spec.md @@ -0,0 +1,112 @@ +# Feature Specification: Codex CLI as a Selectable AI Tool + +**Feature Branch**: `016-codex-ai-tool` +**Created**: 2026-04-24 +**Status**: Draft +**Input**: User description: "Add Codex CLI as a selectable AI tool in `forgekit new` alongside Claude Code; choices are Claude Code, Codex CLI, or None; ProjectConfig replaces `claudeCode` boolean with `aiTool` enum; Codex path emits `AGENTS.md` plus `.codex/` config and rules; `specify init` forwards `--ai codex`; `speckitPreset` prompt is skipped for Codex." + +## User Scenarios & Testing *(mandatory)* + +### User Story 1 — Scaffold a project for Claude Code (Priority: P1) + +A developer who already uses Claude Code runs `forgekit new`, picks **Claude Code** when asked which AI assistant to set up, and ends up with a project that contains the same `CLAUDE.md`, `.claude/` directory, hooks, skills, rules, and (optionally) speckit artifacts that ForgeKit produces today. + +**Why this priority**: Existing users must not regress. This is the baseline behavior the rename touches. + +**Independent Test**: Run `forgekit new`, choose Claude Code + a stack, then assert the generated tree contains `CLAUDE.md`, `.claude/settings.json`, `.claude/rules/`, and zero `.codex/` or `AGENTS.md` artifacts. + +**Acceptance Scenarios**: + +1. **Given** Claude Code CLI is installed, **When** the user picks "Claude Code" at the AI tool prompt, **Then** the generated project contains all current Claude artifacts and no Codex artifacts. +2. **Given** Claude Code CLI is **not** installed, **When** the user opens the AI tool prompt, **Then** the "Claude Code" choice is shown but flagged as unavailable (matching today's behavior). +3. **Given** the user picks Claude Code with workflow mode `speckit`, **When** generation runs, **Then** `specify init` is invoked with `--ai claude`. + +--- + +### User Story 2 — Scaffold a project for Codex CLI (Priority: P1) + +A developer who uses OpenAI Codex CLI runs `forgekit new`, picks **Codex CLI** at the AI tool prompt, and receives a project pre-wired for Codex: an `AGENTS.md` at the project root with embedded stack conventions, a `.codex/config.toml` with sensible sandbox/approval defaults, and a `.codex/rules/` directory carrying the same backend/frontend convention notes (as a human/IDE convention, even though Codex does not natively read sub-files). + +**Why this priority**: This is the new capability the user is asking for. + +**Independent Test**: Run `forgekit new`, choose Codex CLI + a backend + a frontend, then assert the tree contains `AGENTS.md`, `.codex/config.toml`, `.codex/rules/backend.md`, `.codex/rules/frontend.md`, and contains **no** `CLAUDE.md` or `.claude/` directory. + +**Acceptance Scenarios**: + +1. **Given** Codex CLI is installed, **When** the user picks "Codex CLI" at the AI tool prompt, **Then** the generated project contains the four Codex artifacts above and no Claude artifacts. +2. **Given** the user picks Codex with workflow mode `speckit`, **When** generation runs, **Then** `specify init` is invoked with `--ai codex` and the `speckitPreset` prompt is **not** asked. +3. **Given** Codex is chosen, **When** the AI tool prompt is rendered, **Then** the `speckitPreset` follow-up question is unreachable in the prompt flow. +4. **Given** Codex CLI is **not** installed, **When** the user opens the AI tool prompt, **Then** the "Codex CLI" choice is shown but flagged as unavailable, matching the pattern used for Claude. + +--- + +### User Story 3 — Scaffold a project with no AI tooling (Priority: P2) + +A developer wants a clean project skeleton without any AI assistant files. They run `forgekit new`, pick **None**, and the resulting project contains neither `CLAUDE.md` / `.claude/` nor `AGENTS.md` / `.codex/`. + +**Why this priority**: Preserves the current opt-out path (`claudeCode: false`) so users who do not use any agent still get a working scaffold. + +**Independent Test**: Run `forgekit new`, choose None, assert the generated tree contains zero AI-related files at the root or in `.claude/` / `.codex/`. + +**Acceptance Scenarios**: + +1. **Given** the user picks None, **When** generation completes, **Then** no AI-tool files are created. +2. **Given** the user picks None, **When** generation runs, **Then** the workflow-mode and speckit-preset prompts are skipped and no `.specify/` scaffold is created. + +--- + +### Edge Cases + +- The CLI is invoked non-interactively (preset/JSON config). The new `aiTool` field must be required in that path; an unknown or missing value fails fast with a clear error. +- Codex chosen but the `specify` CLI is missing on the user's machine. Speckit init must fail with the same error message currently shown for Claude — no special-casing. +- A previously generated project on disk had `claudeCode: true` written into a saved preset file. Loading the old preset must surface a clear migration error (or be auto-mapped to `aiTool: 'claude'` if a preset loader exists). Behavior to be confirmed during planning. +- Both Claude and Codex CLIs are installed. The prompt must show both as available; only one can be picked. + +## Requirements *(mandatory)* + +### Functional Requirements + +- **FR-001**: The `forgekit new` interactive prompt MUST present a single, mutually-exclusive choice for the AI assistant with three options: Claude Code, Codex CLI, None. +- **FR-002**: The `ProjectConfig` type MUST expose a single field `aiTool` whose value is one of `claude`, `codex`, or `none`. The previous `claudeCode: boolean` field MUST be removed. +- **FR-003**: When `aiTool === 'claude'`, the system MUST produce the same Claude artifacts ForgeKit produces today (CLAUDE.md, `.claude/` settings, hooks, hookify, rules, skills, commands). +- **FR-004**: When `aiTool === 'codex'`, the system MUST produce: `AGENTS.md` at project root, `.codex/config.toml`, `.codex/rules/backend.md`, `.codex/rules/frontend.md`. It MUST NOT produce any Claude artifacts. +- **FR-005**: When `aiTool === 'none'`, the system MUST NOT produce any AI-tool files (no Claude artifacts, no Codex artifacts, no `.specify/`). +- **FR-006**: When `aiTool === 'claude'` AND workflow mode is `speckit`, the system MUST invoke `specify init` with `--ai claude`. +- **FR-007**: When `aiTool === 'codex'` AND workflow mode is `speckit`, the system MUST invoke `specify init` with `--ai codex`. +- **FR-008**: The `speckitPreset` prompt MUST only be shown when `aiTool === 'claude'` AND workflow mode is `speckit`. For Codex it MUST be skipped entirely. +- **FR-009**: The system MUST detect whether each AI CLI is installed before listing it in the prompt, using a synchronous detection (consistent with Constitution §8). Unavailable choices remain visible but flagged. +- **FR-010**: The Codex `AGENTS.md` MUST embed the same per-stack backend and frontend convention text used in the Claude rules, since Codex does not reliably read sub-files. +- **FR-011**: The Codex `.codex/config.toml` MUST set sensible defaults: `sandbox_mode = "workspace-write"` and `approval_policy = "on-request"`. No MCP servers are scaffolded by default. +- **FR-012**: Generation MUST follow the existing fail-fast / full-rollback contract (Constitution §4): any error in the Codex generator deletes the entire project directory. +- **FR-013**: Each AI tool generator MUST own only its own output directory (`.claude/` for Claude, `.codex/` and `AGENTS.md` for Codex), per Constitution §1. + +### Key Entities + +- **AITool**: Enum value identifying which AI assistant the project is set up for. Members: `claude`, `codex`, `none`. +- **ProjectConfig** (modified): The single source of truth for generation. Loses `claudeCode: boolean`, gains `aiTool: AITool`. +- **CodexGenerator**: New generator class responsible exclusively for Codex artifacts (`AGENTS.md`, `.codex/config.toml`, `.codex/rules/`). + +## Success Criteria *(mandatory)* + +### Measurable Outcomes + +- **SC-001**: A user can scaffold a Codex-ready project in a single `forgekit new` run with no manual file creation afterwards. +- **SC-002**: 100% of existing Claude scaffolding tests continue to pass after the rename, with no behavioral change to the Claude artifacts they assert on. +- **SC-003**: A new user-driven smoke run for each of the three `aiTool` values produces the expected file set with zero leakage between profiles (no Claude file appears in a Codex project, no Codex file appears in a Claude project, no AI files appear in a None project). +- **SC-004**: When Codex CLI is selected with workflow mode `speckit`, the `specify` invocation receives `--ai codex` exactly once, verified via a unit test on the speckit generator. +- **SC-005**: The interactive prompt asks at most one extra question compared to today's flow (the AI tool choice itself); no Codex-only follow-up questions are introduced. + +## Assumptions + +- The user's environment will continue to provide `specify` (spec-kit CLI) for both Claude and Codex paths. Detection of `specify` itself remains out of scope for this feature. +- The user explicitly accepted the trade-off that `.codex/rules/` will not be read natively by Codex CLI — it is generated as a human/IDE convention. +- No legacy preset files are stored that pin `claudeCode: true`; if any do exist, a clear failure (rather than silent migration) is acceptable. +- The Codex CLI binary is named `codex` and supports a `--help` invocation suitable for synchronous detection. + +## Out of Scope + +- Support for additional AI tools (Cursor, Gemini, Copilot, Qwen, etc.). +- Generating Codex-specific MCP server configuration. +- Migrating any existing Claude project to Codex (one-shot scaffolding only). +- Refactoring the `BaseGenerator` pattern. +- Extracting a shared rules-rendering module (only two callsites, per Constitution §6). diff --git a/specs/016-codex-ai-tool/tasks.md b/specs/016-codex-ai-tool/tasks.md new file mode 100644 index 0000000..030f927 --- /dev/null +++ b/specs/016-codex-ai-tool/tasks.md @@ -0,0 +1,78 @@ +# Tasks: Codex CLI as a Selectable AI Tool + +**Feature**: `016-codex-ai-tool` | **Spec**: [spec.md](./spec.md) | **Plan**: [plan.md](./plan.md) + +> Cfg: `tdd=false`, `verification=minimal` (lint+typecheck per task, full vitest only in Polish), `code-review=false`, `subagents=false`. Tests are written **after** the implementation in the same task block, not before. + +## Phase 1 — Setup + +- [x] T001 Locate the Claude CLI detection helper to know where to add the Codex sibling. Run `grep -rn "isClaudeInstalled" src/` and record the file path. No code change yet. + +## Phase 2 — Foundational (blocks all user stories) + +- [x] T002 Add `AITool` type and migrate `ProjectConfig` in `src/types.ts`: introduce `export type AITool = 'claude' | 'codex' | 'none'`, replace `claudeCode: boolean` with `aiTool: AITool`. Run `npm run typecheck` to surface every consumer that breaks. +- [x] T003 Mechanically update every TS consumer reported by T002 (loaders, prompts, generators, dispatcher, tests) to swap `config.claudeCode` for `config.aiTool === 'claude'`. Do not change Claude generation behavior. Re-run `npm run typecheck` until clean. +- [x] T004 Add `isCodexInstalled()` next to `isClaudeInstalled()` in the file located by T001. Same shape: `spawnSync('codex', ['--help'], { stdio: 'ignore' })` returning a boolean (Constitution §8). Export it. + +## Phase 3 — [US1] Claude Code path preserved (Priority: P1) + +**Goal**: Existing Claude scaffolding continues to work after the rename — zero behavioral regression. +**Independent test**: `forgekit new` with `aiTool: 'claude'` produces today's tree (CLAUDE.md + .claude/ + rules + speckit) and the existing vitest suite remains green. + +- [x] T005 [US1] Update the dispatch in `src/commands/new.ts` so the existing Claude branch fires on `config.aiTool === 'claude'` instead of `config.claudeCode`. No other change to the Claude generator pipeline. +- [x] T006 [US1] Update the speckit generator at `src/generators/speckit.ts` to forward `--ai claude` when `aiTool === 'claude'`. Skip the call when `aiTool === 'none'`. Keep the function signature stable. +- [x] T007 [P] [US1] Update fixtures in `src/generators/claude-code/__tests__/claude-code.test.ts` to use `aiTool: 'claude'` everywhere instead of `claudeCode: true`. Constitution §7 — every required field declared. (Negative case folded into the dispatcher gate via `if (config.aiTool === 'claude')` in commands/new.ts; existing claude-code generator is only ever called from that branch, so a separate "skipped" assertion would test the dispatcher, not the generator.) +- [x] T008 [US1] Run `npm run lint && npm run typecheck` and the `claude-code` test file in isolation. Green. + +## Phase 4 — [US2] Codex CLI path (Priority: P1) + +**Goal**: Picking Codex emits `AGENTS.md` + `.codex/config.toml` + `.codex/rules/{backend,frontend}.md`, with no Claude leakage. Speckit forwards `--ai codex`. +**Independent test**: `forgekit new` with `aiTool: 'codex'` produces the four Codex files and zero Claude files; speckit invocation receives `--ai codex`. + +- [x] T009 [P] [US2] Create `src/templates/codex/AGENTS.md.hbs` with the prose project overview, command table, workflow-mode banner, git-strategy banner, and **inline** per-stack convention sections (sourced from the same data as Claude's `.claude/rules/*` templates) — Codex only reads `AGENTS.md` itself. +- [x] T010 [P] [US2] Create `src/templates/codex/config.toml.hbs` with `sandbox_mode = "workspace-write"`, `approval_policy = "on-request"`, and an `[mcp_servers]` placeholder comment. +- [x] T011 [P] [US2] Create `src/templates/codex/rules/backend.md.hbs` and `src/templates/codex/rules/frontend.md.hbs`, mirroring the data shape used by `src/templates/claude-code/rules/{backend,frontend}.md.hbs`. +- [x] T012 [US2] Create `src/generators/codex/index.ts` exporting `CodexGenerator extends BaseGenerator`. `generate()` renders the four templates with `Promise.all`, writes `AGENTS.md` to project root, the rest under `.codex/`. +- [x] T013 [US2] Wire the new branch in `src/commands/new.ts` and `src/commands/add.ts` (LAYER_CONFIG_MAP + runLayerGenerator + regenerateDependentLayers). Same try/catch / rollback path as Claude (Constitution §4). +- [x] T014 [US2] Extend `src/generators/speckit.ts` to forward `--ai codex` when `aiTool === 'codex'` and skip when `aiTool === 'none'`. +- [x] T015 [US2] Update `src/prompts/project.ts`: replaced the Claude checkbox with a `list` prompt for `aiTool` (Claude / Codex / None). Detection of both CLIs flags unavailable choices. Re-gated `workflowMode` on `aiTool !== 'none'` and `speckitPreset` on `aiTool === 'claude' && workflowMode === 'speckit'`. +- [x] T016 [US2] Created `src/generators/codex/__tests__/codex.test.ts` (9 cases: file presence, per-stack rule presence, FastAPI rendering, no Claude leakage, speckit + git-strategy banner rendering). +- [x] T017 [US2] Added speckit test cases asserting `--ai claude` / `--ai codex` forwarding and the no-op when `aiTool === 'none'`. +- [x] T018 [US2] Lint + typecheck + scoped vitest green. + +## Phase 5 — [US3] None path (Priority: P2) + +**Goal**: Picking "None" produces zero AI files. +**Independent test**: `forgekit new` with `aiTool: 'none'` writes neither Claude nor Codex artifacts and skips speckit init. + +- [x] T019 [US3] `src/commands/new.ts` dispatcher confirmed: only fires Claude or Codex when `config.aiTool === '...'`; `none` skips both. Speckit init is gated by `config.speckit && config.aiTool !== 'none'`. Prompt skips workflowMode and speckitPreset for `aiTool === 'none'`. +- [x] T020 [P] [US3] Coverage for the "none" path is provided by the existing `e2e.test.ts` cases (which use `aiTool: 'none'` via the default fixture for non-Claude scenarios) and by `speckit.test.ts` (asserts `spawnSync` is not called when `aiTool === 'none'`). A dedicated `commands/__tests__/new.test.ts` would mostly re-test Commander wiring; skipped as not adding signal. + +## Phase 6 — Polish & verification + +- [x] T021 Full `npm test` suite — green. +- [x] T022 `npm run build` — green; codex templates copied into `dist/templates/codex/`. +- [x] T023 Manual smoke partial: ran the built CLI with `--ai-tool codex` and confirmed the prompt branches into "Workflow mode (Codex CLI)". Full end-to-end leakage assertions are already covered by `codex.test.ts` and the existing claude-code test suite, so this was not re-run interactively. +- [x] T024 Confirmed `LAYER_CONFIG_MAP` and `runLayerGenerator` DO exist in `src/commands/add.ts` (Explore agent missed them). Memory note `feedback_projectconfig_new_field.md` is accurate — leaving it untouched. + +## Dependencies + +- T002 → T003 (rename ripple). +- T003 → T005, T007, T015, T020 (consumers). +- T001 → T004 (need the file path first). +- T009/T010/T011 [P] → T012 (templates feed the generator). +- T012 → T013, T016, T018. +- T006 → T014 (extend, don't conflict). +- T015 depends on both T004 and T013 indirectly (prompt references both detections + needs the new branch wired). +- All US phases must complete before T021 (full suite). +- T021/T022 must pass before T023. + +## Parallel opportunities + +- T009 + T010 + T011 are independent template files → run in parallel. +- T007 (Claude test fixtures update) is independent of any new Codex code → can run in parallel with T009–T011. +- T020 (None-path test) only depends on T013 + T015 → can run in parallel with T016/T017 once those land. + +## MVP scope + +US1 + US2 form the MVP (Codex support without regressing Claude). US3 is a small assertion task on top. diff --git a/src/__tests__/e2e.test.ts b/src/__tests__/e2e.test.ts index 1ab846f..5aaa366 100644 --- a/src/__tests__/e2e.test.ts +++ b/src/__tests__/e2e.test.ts @@ -191,7 +191,7 @@ describe("ForgeKit e2e — generation pipeline", () => { baseConfig({ backendType: null, frontend: null, - claudeCode: true, + aiTool: "claude", }), ); @@ -220,7 +220,7 @@ describe("ForgeKit e2e — generation pipeline", () => { frontend: "react-vite", docker: true, ci: true, - claudeCode: true, + aiTool: "claude", speckit: true, prettier: true, }), diff --git a/src/__tests__/fixtures.ts b/src/__tests__/fixtures.ts index e5d6a6c..2940765 100644 --- a/src/__tests__/fixtures.ts +++ b/src/__tests__/fixtures.ts @@ -22,7 +22,7 @@ export function makeBaseConfig( ngrx: false, docker: false, ci: false, - claudeCode: false, + aiTool: "none", speckit: false, workflowMode: "none", gitStrategy: "pr-required", diff --git a/src/commands/add.ts b/src/commands/add.ts index fa49b69..213c314 100644 --- a/src/commands/add.ts +++ b/src/commands/add.ts @@ -16,6 +16,7 @@ import { generateNextJsBackend } from "../generators/nextjs/index.js"; import { generateDocker } from "../generators/docker/index.js"; import { generateCI } from "../generators/ci/index.js"; import { generateClaudeCode } from "../generators/claude-code/index.js"; +import { generateCodex } from "../generators/codex/index.js"; import { initSpecify } from "../generators/speckit.js"; import type { ProjectConfig } from "../types.js"; import type { ResolvedVersions } from "../versions.js"; @@ -23,7 +24,7 @@ import type { ResolvedVersions } from "../versions.js"; interface LayerDef { configField: keyof ProjectConfig; configValue: string | boolean; - conflictGroup: "backend" | "frontend" | null; + conflictGroup: "backend" | "frontend" | "ai-tool" | null; } const LAYER_CONFIG_MAP: Record = { @@ -65,9 +66,14 @@ const LAYER_CONFIG_MAP: Record = { docker: { configField: "docker", configValue: true, conflictGroup: null }, ci: { configField: "ci", configValue: true, conflictGroup: null }, "claude-code": { - configField: "claudeCode", - configValue: true, - conflictGroup: null, + configField: "aiTool", + configValue: "claude", + conflictGroup: "ai-tool", + }, + codex: { + configField: "aiTool", + configValue: "codex", + conflictGroup: "ai-tool", }, speckit: { configField: "speckit", configValue: true, conflictGroup: null }, prettier: { @@ -92,6 +98,9 @@ function checkConflict(layer: string, config: ProjectConfig): string | null { if (def.conflictGroup === "frontend" && config.frontend !== null) { return `A frontend (${config.frontend}) already exists. Remove it before adding a new one.`; } + if (def.conflictGroup === "ai-tool" && config.aiTool !== "none") { + return `An AI tool (${config.aiTool}) is already configured. Remove it before adding a new one.`; + } if ( def.conflictGroup === null && config[def.configField as keyof ProjectConfig] === true @@ -134,8 +143,11 @@ async function runLayerGenerator( case "claude-code": await generateClaudeCode(projectDir, config, versions); break; + case "codex": + await generateCodex(projectDir, config, versions); + break; case "speckit": - initSpecify(projectDir); + initSpecify(projectDir, config.aiTool); break; case "prettier": case "eslint": @@ -173,10 +185,14 @@ async function regenerateDependentLayers( console.log(chalk.green("\r ✔ GitHub Actions CI mis à jour ")); } - if (config.claudeCode) { + if (config.aiTool === "claude") { process.stdout.write(chalk.yellow(" ⏳ Mise à jour Claude Code...")); await generateClaudeCode(projectDir, config, versions); console.log(chalk.green("\r ✔ Claude Code mis à jour ")); + } else if (config.aiTool === "codex") { + process.stdout.write(chalk.yellow(" ⏳ Mise à jour Codex CLI...")); + await generateCodex(projectDir, config, versions); + console.log(chalk.green("\r ✔ Codex CLI mis à jour ")); } } @@ -284,7 +300,7 @@ Exemples: ngrx: false, docker: false, ci: false, - claudeCode: false, + aiTool: "none", speckit: false, workflowMode: "none", gitStrategy: "pr-required", @@ -303,8 +319,10 @@ Exemples: console.log(chalk.gray(` Frontend: ${existingConfig.frontend}`)); if (existingConfig.docker) console.log(chalk.gray(" Docker: yes")); if (existingConfig.ci) console.log(chalk.gray(" CI: yes")); - if (existingConfig.claudeCode) + if (existingConfig.aiTool === "claude") console.log(chalk.gray(" Claude Code: yes")); + if (existingConfig.aiTool === "codex") + console.log(chalk.gray(" Codex CLI: yes")); if (existingConfig.speckit) console.log(chalk.gray(" Speckit: yes")); if (existingConfig.prettier) console.log(chalk.gray(" Prettier: yes")); console.log(""); diff --git a/src/commands/new.ts b/src/commands/new.ts index 822e17d..c81dd66 100644 --- a/src/commands/new.ts +++ b/src/commands/new.ts @@ -9,6 +9,7 @@ import { generateFrontend } from "../generators/frontend/index.js"; import { generateDocker } from "../generators/docker/index.js"; import { generateCI } from "../generators/ci/index.js"; import { generateClaudeCode } from "../generators/claude-code/index.js"; +import { generateCodex } from "../generators/codex/index.js"; import { generateFastAPIBackend } from "../generators/fastapi/index.js"; import { generateLaravelBackend } from "../generators/laravel/index.js"; import { generateNestJsBackend } from "../generators/nestjs/index.js"; @@ -24,6 +25,7 @@ import type { BackendType, FrontendType, WorkflowMode, + AITool, } from "../types.js"; export async function generateProject( @@ -117,7 +119,7 @@ export async function generateProject( console.log(chalk.green("\r ✔ GitHub Actions CI configuré ")); } - if (config.claudeCode) { + if (config.aiTool === "claude") { process.stdout.write(chalk.yellow(" ⏳ Claude Code...")); const { speckitWorkflowCopied } = await generateClaudeCode( projectDir, @@ -134,11 +136,15 @@ export async function generateProject( ), ); } + } else if (config.aiTool === "codex") { + process.stdout.write(chalk.yellow(" ⏳ Codex CLI...")); + await generateCodex(projectDir, config, versions); + console.log(chalk.green("\r ✔ Codex CLI configuré ")); } - if (config.speckit) { + if (config.speckit && config.aiTool !== "none") { process.stdout.write(chalk.yellow(" ⏳ Speckit...")); - initSpecify(projectDir); + initSpecify(projectDir, config.aiTool); console.log(chalk.green("\r ✔ Speckit initialisé ")); } @@ -188,8 +194,7 @@ export const newCommand = new Command("new") .option("--no-docker", "Exclure Docker Compose") .option("--ci", "Inclure GitHub Actions CI") .option("--no-ci", "Exclure GitHub Actions CI") - .option("--claude-code", "Inclure config Claude Code") - .option("--no-claude-code", "Exclure config Claude Code") + .option("--ai-tool ", "Assistant IA : claude | codex | none") .option("--prettier", "Inclure Prettier + Husky + lint-staged") .option("--no-prettier", "Exclure Prettier") .option("--workflow ", "Mode workflow Claude : speckit | vibe | none") @@ -210,7 +215,7 @@ Frontends: Infrastructure: --docker Docker Compose (PostgreSQL + pgAdmin) [défaut: oui si backend] --ci GitHub Actions CI [défaut: oui si stack] - --claude-code Config Claude Code [défaut: si claude CLI détecté] + --ai-tool Assistant IA : claude | codex | none [défaut: claude si détecté] --prettier Prettier + Husky + lint-staged [défaut: non] --no-git Ne pas initialiser Git @@ -259,8 +264,18 @@ Exemples: cmd.getOptionValueSource(key) === "cli"; if (isExplicit("ci")) defaults.ci = options.ci as boolean; if (isExplicit("docker")) defaults.docker = options.docker as boolean; - if (isExplicit("claudeCode")) - defaults.claudeCode = options.claudeCode as boolean; + if (options.aiTool) { + const value = options.aiTool as string; + if (value !== "claude" && value !== "codex" && value !== "none") { + console.log( + chalk.red( + `\n✖ --ai-tool invalide : "${value}". Valeurs acceptées : claude, codex, none.`, + ), + ); + process.exit(1); + } + defaults.aiTool = value as AITool; + } if (options.workflow) defaults.workflowMode = options.workflow as WorkflowMode; if (isExplicit("git")) defaults.gitInit = options.git as boolean; diff --git a/src/generators/__tests__/speckit.test.ts b/src/generators/__tests__/speckit.test.ts index 4fc5830..16f2f60 100644 --- a/src/generators/__tests__/speckit.test.ts +++ b/src/generators/__tests__/speckit.test.ts @@ -8,11 +8,11 @@ import { initSpecify } from "../speckit.js"; import { spawnSync } from "node:child_process"; describe("initSpecify", () => { - it("calls specify init with correct args in the project directory", () => { + it("forwards --ai claude when aiTool is claude", () => { vi.mocked(spawnSync).mockReturnValue({ status: 0 } as ReturnType< typeof spawnSync >); - const result = initSpecify("/tmp/my-project"); + const result = initSpecify("/tmp/my-project", "claude"); expect(spawnSync).toHaveBeenCalledWith( "specify", ["init", "--here", "--ai", "claude", "--no-git"], @@ -21,10 +21,30 @@ describe("initSpecify", () => { expect(result).toBe(true); }); - it("returns false when specify init fails", () => { + it("forwards --ai codex when aiTool is codex", () => { + vi.mocked(spawnSync).mockReturnValue({ status: 0 } as ReturnType< + typeof spawnSync + >); + const result = initSpecify("/tmp/my-project", "codex"); + expect(spawnSync).toHaveBeenCalledWith( + "specify", + ["init", "--here", "--ai", "codex", "--no-git"], + { cwd: "/tmp/my-project", stdio: "inherit" }, + ); + expect(result).toBe(true); + }); + + it("returns false without invoking specify when aiTool is none", () => { + vi.mocked(spawnSync).mockClear(); + const result = initSpecify("/tmp/my-project", "none"); + expect(spawnSync).not.toHaveBeenCalled(); + expect(result).toBe(false); + }); + + it("returns false when specify init exits non-zero", () => { vi.mocked(spawnSync).mockReturnValue({ status: 1 } as ReturnType< typeof spawnSync >); - expect(initSpecify("/tmp/my-project")).toBe(false); + expect(initSpecify("/tmp/my-project", "claude")).toBe(false); }); }); diff --git a/src/generators/claude-code/__tests__/claude-code.test.ts b/src/generators/claude-code/__tests__/claude-code.test.ts index 5e19103..69e0859 100644 --- a/src/generators/claude-code/__tests__/claude-code.test.ts +++ b/src/generators/claude-code/__tests__/claude-code.test.ts @@ -9,7 +9,7 @@ import { makeBaseConfig, BASE_VERSIONS } from "../../../__tests__/fixtures.js"; let fakeSkillsDir: string; let fakeCommandsDir: string; -const baseConfig = makeBaseConfig({ claudeCode: true }); +const baseConfig = makeBaseConfig({ aiTool: "claude" }); const baseVersions = BASE_VERSIONS; describe("ClaudeCodeGenerator", () => { diff --git a/src/generators/codex/__tests__/codex.test.ts b/src/generators/codex/__tests__/codex.test.ts new file mode 100644 index 0000000..b90a32a --- /dev/null +++ b/src/generators/codex/__tests__/codex.test.ts @@ -0,0 +1,111 @@ +import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import fs from "fs-extra"; +import path from "node:path"; +import os from "node:os"; +import { generateCodex } from "../index.js"; +import { makeBaseConfig, BASE_VERSIONS } from "../../../__tests__/fixtures.js"; + +const baseConfig = makeBaseConfig({ aiTool: "codex" }); +const baseVersions = BASE_VERSIONS; + +describe("CodexGenerator", () => { + let tmpDir: string; + + beforeEach(async () => { + tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "forgekit-codex-test-")); + }); + + afterEach(async () => { + await fs.remove(tmpDir); + }); + + it("creates AGENTS.md at the project root", async () => { + await generateCodex(tmpDir, baseConfig, baseVersions); + expect(await fs.pathExists(path.join(tmpDir, "AGENTS.md"))).toBe(true); + }); + + it("creates .codex/config.toml with default sandbox + approval values", async () => { + await generateCodex(tmpDir, baseConfig, baseVersions); + const tomlPath = path.join(tmpDir, ".codex", "config.toml"); + expect(await fs.pathExists(tomlPath)).toBe(true); + const content = await fs.readFile(tomlPath, "utf-8"); + expect(content).toContain('sandbox_mode = "workspace-write"'); + expect(content).toContain('approval_policy = "on-request"'); + }); + + it("creates .codex/rules/backend.md when a backend is configured", async () => { + const config = makeBaseConfig({ + aiTool: "codex", + backendType: "spring-boot", + }); + await generateCodex(tmpDir, config, baseVersions); + const rulesPath = path.join(tmpDir, ".codex", "rules", "backend.md"); + expect(await fs.pathExists(rulesPath)).toBe(true); + const content = await fs.readFile(rulesPath, "utf-8"); + expect(content).toContain("Spring Boot"); + }); + + it("creates .codex/rules/frontend.md when a frontend is configured", async () => { + const config = makeBaseConfig({ + aiTool: "codex", + frontend: "angular", + }); + await generateCodex(tmpDir, config, baseVersions); + const rulesPath = path.join(tmpDir, ".codex", "rules", "frontend.md"); + expect(await fs.pathExists(rulesPath)).toBe(true); + const content = await fs.readFile(rulesPath, "utf-8"); + expect(content).toContain("Angular"); + }); + + it("skips backend/frontend rules when neither stack is configured", async () => { + await generateCodex(tmpDir, baseConfig, baseVersions); + expect( + await fs.pathExists(path.join(tmpDir, ".codex", "rules", "backend.md")), + ).toBe(false); + expect( + await fs.pathExists(path.join(tmpDir, ".codex", "rules", "frontend.md")), + ).toBe(false); + }); + + it("never writes Claude artifacts (no CLAUDE.md, no .claude/)", async () => { + const config = makeBaseConfig({ + aiTool: "codex", + backendType: "fastapi", + frontend: "react-vite", + }); + await generateCodex(tmpDir, config, baseVersions); + expect(await fs.pathExists(path.join(tmpDir, "CLAUDE.md"))).toBe(false); + expect(await fs.pathExists(path.join(tmpDir, ".claude"))).toBe(false); + }); + + it("renders FastAPI guidance in AGENTS.md when backend is fastapi", async () => { + const config = makeBaseConfig({ + aiTool: "codex", + backendType: "fastapi", + }); + await generateCodex(tmpDir, config, baseVersions); + const agents = await fs.readFile(path.join(tmpDir, "AGENTS.md"), "utf-8"); + expect(agents).toContain("FastAPI"); + expect(agents).toContain("uvicorn"); + }); + + it("renders speckit workflow banner when workflowMode is speckit", async () => { + const config = makeBaseConfig({ + aiTool: "codex", + workflowMode: "speckit", + }); + await generateCodex(tmpDir, config, baseVersions); + const agents = await fs.readFile(path.join(tmpDir, "AGENTS.md"), "utf-8"); + expect(agents).toContain("Workflow Mode: speckit"); + }); + + it("renders no-PR git note when gitStrategy is no-pr", async () => { + const config = makeBaseConfig({ + aiTool: "codex", + gitStrategy: "no-pr", + }); + await generateCodex(tmpDir, config, baseVersions); + const agents = await fs.readFile(path.join(tmpDir, "AGENTS.md"), "utf-8"); + expect(agents).toContain("Merge direct sur `master`"); + }); +}); diff --git a/src/generators/codex/index.ts b/src/generators/codex/index.ts new file mode 100644 index 0000000..bb5a113 --- /dev/null +++ b/src/generators/codex/index.ts @@ -0,0 +1,95 @@ +import path from "node:path"; +import fs from "fs-extra"; +import { renderAndWrite } from "../../utils/template-engine.js"; +import { BaseGenerator } from "../base-generator.js"; +import type { ProjectConfig } from "../../types.js"; +import type { ResolvedVersions } from "../../versions.js"; + +class CodexGenerator extends BaseGenerator { + private readonly versions: ResolvedVersions; + + constructor( + projectDir: string, + config: ProjectConfig, + versions: ResolvedVersions, + ) { + super(projectDir, config); + this.versions = versions; + } + + async generate(): Promise { + const codexDir = path.join(this.projectDir, ".codex"); + await fs.ensureDir(codexDir); + + const backend = this.config.backendType !== null; + const hasFrontend = this.config.frontend !== null; + + if (backend || hasFrontend) { + await fs.ensureDir(path.join(codexDir, "rules")); + } + + const data = { + name: this.config.name, + description: this.config.description, + backend, + springBoot: this.config.backendType === "spring-boot", + fastapi: this.config.backendType === "fastapi", + laravel: this.config.backendType === "laravel", + nextjs: this.config.backendType === "nextjs", + hasFrontend, + angular: this.config.frontend === "angular", + reactVite: this.config.frontend === "react-vite", + vue: this.config.frontend === "vue", + docker: this.config.docker, + flyway: this.config.flyway, + ngrx: this.config.ngrx, + auth: this.config.auth, + prisma: this.config.prisma, + versions: this.versions, + workflowSpeckit: this.config.workflowMode === "speckit", + workflowVibe: this.config.workflowMode === "vibe", + gitStrategy: this.config.gitStrategy, + gitStrategyNoPr: this.config.gitStrategy === "no-pr", + }; + + await Promise.all([ + renderAndWrite( + "codex/AGENTS.md.hbs", + path.join(this.projectDir, "AGENTS.md"), + data, + ), + renderAndWrite( + "codex/config.toml.hbs", + path.join(codexDir, "config.toml"), + data, + ), + ...(backend + ? [ + renderAndWrite( + "codex/rules/backend.md.hbs", + path.join(codexDir, "rules", "backend.md"), + data, + ), + ] + : []), + ...(hasFrontend + ? [ + renderAndWrite( + "codex/rules/frontend.md.hbs", + path.join(codexDir, "rules", "frontend.md"), + data, + ), + ] + : []), + ]); + } +} + +export async function generateCodex( + projectDir: string, + config: ProjectConfig, + versions: ResolvedVersions, +): Promise { + const generator = new CodexGenerator(projectDir, config, versions); + await generator.generate(); +} diff --git a/src/generators/speckit.ts b/src/generators/speckit.ts index f649764..a3f8367 100644 --- a/src/generators/speckit.ts +++ b/src/generators/speckit.ts @@ -1,9 +1,11 @@ import { spawnSync } from "node:child_process"; +import type { AITool } from "../types.js"; -export function initSpecify(projectDir: string): boolean { +export function initSpecify(projectDir: string, aiTool: AITool): boolean { + if (aiTool === "none") return false; const result = spawnSync( "specify", - ["init", "--here", "--ai", "claude", "--no-git"], + ["init", "--here", "--ai", aiTool, "--no-git"], { cwd: projectDir, stdio: "inherit" }, ); return result.status === 0; diff --git a/src/index.ts b/src/index.ts index 2e70958..74e7484 100644 --- a/src/index.ts +++ b/src/index.ts @@ -26,7 +26,7 @@ Workflow: Layers disponibles: Backends spring-boot | fastapi | laravel Frontends angular | react - Infra docker | ci | claude-code | speckit | prettier + Infra docker | ci | claude-code | codex | speckit | prettier Exemples: $ forgekit new my-app diff --git a/src/prompts/project.ts b/src/prompts/project.ts index 6f8087d..a1eb3d7 100644 --- a/src/prompts/project.ts +++ b/src/prompts/project.ts @@ -2,7 +2,11 @@ import { input, confirm, checkbox, select } from "@inquirer/prompts"; import path from "node:path"; import { loadConfig } from "../config.js"; import { validateProjectName, validateGroupId } from "../utils/validation.js"; -import { isClaudeInstalled, isSpecifyInstalled } from "../utils/system.js"; +import { + isClaudeInstalled, + isCodexInstalled, + isSpecifyInstalled, +} from "../utils/system.js"; import type { ProjectConfig, UIFramework, @@ -12,6 +16,7 @@ import type { WorkflowMode, GitStrategy, SpeckitPreset, + AITool, } from "../types.js"; export async function promptProjectConfig( @@ -237,7 +242,7 @@ export async function promptProjectConfig( // ── Section 5: Infrastructure ───────────────────────────────────────────── let docker = defaults.docker ?? true; let ci = defaults.ci ?? true; - let claudeCode = defaults.claudeCode ?? true; + let aiTool: AITool = defaults.aiTool ?? "claude"; let speckit = defaults.speckit ?? true; let workflowMode: WorkflowMode = defaults.workflowMode ?? "none"; let gitInit = defaults.gitInit ?? true; @@ -247,14 +252,12 @@ export async function promptProjectConfig( if ( defaults.docker === undefined && defaults.ci === undefined && - defaults.claudeCode === undefined && defaults.speckit === undefined && defaults.gitInit === undefined && defaults.prettier === undefined && defaults.eslint === undefined ) { const hasBackend = backendType !== null; - const claudeDetected = isClaudeInstalled(); const specifyDetected = isSpecifyInstalled(); const infra = await checkbox({ message: "Infrastructure", @@ -269,13 +272,6 @@ export async function promptProjectConfig( value: "ci", checked: hasBackend || frontend !== null, }, - { - name: claudeDetected - ? "Claude Code" - : "Claude Code (claude CLI non détecté)", - value: "claudeCode", - checked: claudeDetected, - }, { name: specifyDetected ? "Speckit (specify templates)" @@ -300,16 +296,39 @@ export async function promptProjectConfig( }); docker = infra.includes("docker"); ci = infra.includes("ci"); - claudeCode = infra.includes("claudeCode"); speckit = infra.includes("speckit"); gitInit = infra.includes("gitInit"); prettier = infra.includes("prettier"); eslint = infra.includes("eslint"); } - if (claudeCode && defaults.workflowMode === undefined) { + if (defaults.aiTool === undefined) { + const claudeDetected = isClaudeInstalled(); + const codexDetected = isCodexInstalled(); + aiTool = await select({ + message: "Assistant IA", + choices: [ + { + name: claudeDetected + ? "Claude Code" + : "Claude Code (claude CLI non détecté)", + value: "claude", + }, + { + name: codexDetected + ? "Codex CLI" + : "Codex CLI (codex CLI non détecté)", + value: "codex", + }, + { name: "Aucun", value: "none" }, + ], + default: claudeDetected ? "claude" : codexDetected ? "codex" : "none", + }); + } + + if (aiTool !== "none" && defaults.workflowMode === undefined) { workflowMode = await select({ - message: "Workflow mode (Claude Code)", + message: `Workflow mode (${aiTool === "claude" ? "Claude Code" : "Codex CLI"})`, choices: [ { name: "speckit — spec → plan → tasks → impl → review → PR", @@ -323,7 +342,11 @@ export async function promptProjectConfig( } let speckitPreset: SpeckitPreset | null = defaults.speckitPreset ?? null; - if (workflowMode === "speckit" && defaults.speckitPreset === undefined) { + if ( + aiTool === "claude" && + workflowMode === "speckit" && + defaults.speckitPreset === undefined + ) { speckitPreset = await select({ message: "Speckit preset", choices: [ @@ -386,7 +409,7 @@ export async function promptProjectConfig( gitStrategy, speckitPreset, ci, - claudeCode, + aiTool, gitInit, prettier, eslint, diff --git a/src/templates/codex/AGENTS.md.hbs b/src/templates/codex/AGENTS.md.hbs new file mode 100644 index 0000000..6761421 --- /dev/null +++ b/src/templates/codex/AGENTS.md.hbs @@ -0,0 +1,142 @@ +# {{name}} + +{{description}} + +{{#if workflowSpeckit}} +## Workflow Mode: speckit +{{/if}} +{{#if workflowVibe}} +## Workflow Mode: vibe +{{/if}} + +## Git + +{{#if gitStrategyNoPr}} +- Merge direct sur `master` — pas de PR requis. +{{else}} +- PR obligatoire avant tout merge sur `master` — pas de push direct. +{{/if}} + +## Architecture Constitution + +Read `.specify/memory/constitution.md` before any architectural decision. + +{{#if docker}} +## Infrastructure + +- **Docker:** `docker compose up -d` | `docker compose down` + +{{/if}} +{{#if springBoot}} +## Backend — Spring Boot {{versions.springBoot}} + +- **Java 21** — records, pattern matching, sealed classes, virtual threads +- **Architecture:** `com...{domain,application,infrastructure}`{{#if flyway}} +- **DB:** PostgreSQL, Flyway migrations in `backend/src/main/resources/db/migration/` — never modify existing migration files{{/if}} +- **Conventions:** Records for DTOs, Lombok, MapStruct for mappings +- **Validation:** Jakarta Bean Validation | **Logging:** `@Slf4j` +- **Config:** `application.yml` (never `application.properties`) + +### Commands +```bash +cd backend +./mvnw spring-boot:run # Start dev server +./mvnw test # Run tests +./mvnw package # Build jar +``` +{{/if}} +{{#if fastapi}} +## Backend — FastAPI (Python 3.12) + +- **FastAPI** with uvicorn, pydantic-settings +- **Architecture:** `app/routers/`, `app/config.py`, `tests/` +- **Conventions:** Pydantic models, async endpoints, dependency injection +- **HTTP status codes:** POST (create) → `201`, GET → `200`, PUT/PATCH → `200`, DELETE → `204` + +### Commands +```bash +cd backend +.venv/bin/uvicorn app.main:app --reload # Start dev server +.venv/bin/pytest tests/ -v # Run tests +.venv/bin/ruff check . # Lint +``` +{{/if}} +{{#if laravel}} +## Backend — Laravel {{versions.laravel}} (PHP 8.3+) + +- **Architecture:** `app/Http/Controllers/`, `app/Models/`, `app/Services/`, `database/migrations/` +- **Conventions:** Form Requests for validation, API Resources for serialization, Eloquent over raw SQL, typed properties everywhere +- **HTTP status codes:** POST (create) → `201`, GET → `200`, PUT/PATCH → `200`, DELETE → `204` + +### Commands +```bash +cd backend +php artisan serve # Start dev server +php artisan test # Run tests (Pest/PHPUnit) +php artisan migrate # Run DB migrations +./vendor/bin/pint # Format / lint +``` +{{/if}} +{{#if nextjs}} +## Backend — Next.js {{versions.next}} (App Router) + +- **Architecture:** `app/` (routes + server components), `app/api/` (route handlers), `lib/`, `components/` +- **Conventions:** Server components by default, `"use client"` only when strictly needed{{#if prisma}} +- **DB:** Prisma ORM — schema in `prisma/schema.prisma`, never edit generated client{{/if}}{{#if auth}} +- **Auth:** NextAuth {{versions.nextAuth}}{{/if}} + +### Commands +```bash +npm run dev # Start dev server (port 3000) +npm run build # Production build +npm run lint # ESLint{{#if prisma}} +npx prisma migrate dev # Run DB migrations +npx prisma studio # Open DB GUI{{/if}} +``` +{{/if}} +{{#if angular}} +## Frontend — Angular {{versions.angular}} + +- **Angular {{versions.angular}}** — strict mode, standalone components, signals +- **UI:** PrimeNG {{versions.primeng}}, theme Aura, PrimeFlex{{#if ngrx}} +- **State:** NgRx SignalStore{{/if}} +- **Conventions:** OnPush, `input()`/`output()`, `inject()`, `@if`/`@for` control flow + +### Commands +```bash +cd frontend +ng serve # Start dev server (port 4200) +ng test # Run unit tests +ng build # Production build +``` +{{/if}} +{{#if reactVite}} +## Frontend — React {{versions.react}} (Vite + Tailwind) + +- **React {{versions.react}}** — functional components, hooks-first, no class components +- **Router:** React Router v7 (declarative mode) +- **CSS:** Tailwind CSS v4 + +### Commands +```bash +cd frontend +npm run dev # Start dev server (port 4200) +npm run build # Production build +npm run lint # TypeScript check +``` +{{/if}} +{{#if vue}} +## Frontend — Vue {{versions.vue}} (Composition API + Vite) + +- **Vue {{versions.vue}}** — `