diff --git a/README.md b/README.md index cca5efc..a85e710 100644 --- a/README.md +++ b/README.md @@ -224,9 +224,11 @@ specify init --ai claude --ignore-agent-tools > [!NOTE] > Codex CLI specifics -> - `specify init --ai codex` ensures a workspace-level `commands/` directory exists (seeding `specify.md`, `plan.md`, `tasks.md` if necessary) because Codex CLI loads slash commands from the repo itself. +> - `specify init --ai codex` seeds `commands/*.md` in your repo and automatically mirrors them into `${CODEX_HOME:-~/.codex}/prompts` so Codex picks up `/specify`, `/plan`, and `/tasks` immediately. +> - If Codex was running during installation, restart the CLI once so it reloads the refreshed slash commands. > - Codex persists its working memory in `AGENTS.md`; if you do not see that file yet, run `codex /init` once inside the project to generate it. -> - To let Codex trigger the helper scripts under `scripts/` through slash commands, open `codex /approvals` and enable “Run shell commands”. +> - When Codex is configured to run helper scripts, open `codex /approvals` and enable “Run shell commands”. +> - If you point `CODEX_HOME` at the project (for per-repo isolation) the installer adds ignore rules for Codex session artifacts to `.gitignore`; adjust them as needed for your workflow. ### **STEP 1:** Bootstrap the project diff --git a/scripts/bash/create-new-feature.sh b/scripts/bash/create-new-feature.sh index bc4b406..575e714 100644 --- a/scripts/bash/create-new-feature.sh +++ b/scripts/bash/create-new-feature.sh @@ -18,7 +18,21 @@ if [ -z "$FEATURE_DESCRIPTION" ]; then exit 1 fi -REPO_ROOT=$(git rev-parse --show-toplevel) +# Resolve repository root. Prefer git information when available, but fall back +# to the script location so the workflow still functions in repositories that +# were initialised with --no-git. +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +FALLBACK_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)" +if git rev-parse --show-toplevel >/dev/null 2>&1; then + REPO_ROOT=$(git rev-parse --show-toplevel) + HAS_GIT=true +else + REPO_ROOT="$FALLBACK_ROOT" + HAS_GIT=false +fi + +cd "$REPO_ROOT" + SPECS_DIR="$REPO_ROOT/specs" mkdir -p "$SPECS_DIR" @@ -40,7 +54,11 @@ BRANCH_NAME=$(echo "$FEATURE_DESCRIPTION" | tr '[:upper:]' '[:lower:]' | sed 's/ WORDS=$(echo "$BRANCH_NAME" | tr '-' '\n' | grep -v '^$' | head -3 | tr '\n' '-' | sed 's/-$//') BRANCH_NAME="${FEATURE_NUM}-${WORDS}" -git checkout -b "$BRANCH_NAME" +if [ "$HAS_GIT" = true ]; then + git checkout -b "$BRANCH_NAME" +else + >&2 echo "[specify] Warning: Git repository not detected; skipped branch creation for $BRANCH_NAME" +fi FEATURE_DIR="$SPECS_DIR/$BRANCH_NAME" mkdir -p "$FEATURE_DIR" diff --git a/src/specify_cli/__init__.py b/src/specify_cli/__init__.py index 842909c..fdac7d8 100644 --- a/src/specify_cli/__init__.py +++ b/src/specify_cli/__init__.py @@ -950,6 +950,124 @@ def ensure_workspace_commands(project_path: Path, tracker: StepTracker | None = console.print(f"[yellow]Warning: could not ensure commands directory ({exc})[/yellow]") +def _resolve_codex_home() -> Path: + env_value = os.environ.get("CODEX_HOME") + if env_value: + return Path(env_value).expanduser() + return Path.home() / ".codex" + + +def _ensure_gitignore_entries(project_path: Path, entries: list[str]) -> None: + if not entries: + return + + gitignore_path = project_path / ".gitignore" + + existing_text = "" + existing: set[str] = set() + if gitignore_path.exists(): + try: + existing_text = gitignore_path.read_text(encoding="utf-8") + existing = {line.strip() for line in existing_text.splitlines()} + except Exception: + return + + new_entries = [entry for entry in entries if entry not in existing] + if not new_entries: + return + + try: + with gitignore_path.open("a", encoding="utf-8") as fh: + if existing_text and not existing_text.endswith("\n"): + fh.write("\n") + for entry in new_entries: + fh.write(f"{entry}\n") + except Exception: + return + + +def sync_codex_prompts(project_path: Path, tracker: StepTracker | None = None) -> None: + if tracker: + tracker.start("codex-prompts") + + commands_dir = project_path / "commands" + if not commands_dir.is_dir(): + if tracker: + tracker.skip("codex-prompts", "no commands directory") + return + + try: + codex_home = _resolve_codex_home() + prompts_dir = (codex_home / "prompts").expanduser() + prompts_dir.mkdir(parents=True, exist_ok=True) + + if not os.access(prompts_dir, os.W_OK): + raise PermissionError(f"Codex prompts directory not writable: {prompts_dir}") + + expected: set[str] = set() + copied = 0 + skipped = 0 + + for source in sorted(commands_dir.glob("*.md")): + if not source.is_file(): + continue + dest_name = source.name + dest_path = prompts_dir / dest_name + expected.add(dest_name) + + data = source.read_bytes() + if dest_path.exists(): + try: + if dest_path.read_bytes() == data: + skipped += 1 + continue + except Exception: + pass + dest_path.write_bytes(data) + copied += 1 + + # Clean up any legacy spec-kit-prefixed prompts from earlier installer versions + for legacy in prompts_dir.glob("spec-kit-*.md"): + try: + legacy.unlink() + except Exception: + continue + + detail_bits = [] + if copied: + detail_bits.append(f"{copied} updated") + if skipped: + detail_bits.append(f"{skipped} unchanged") + detail = ", ".join(detail_bits) if detail_bits else "ok" + + if tracker: + tracker.complete("codex-prompts", detail) + + # If CODEX_HOME lives inside this project, make sure generated files stay untracked + try: + codex_home_relative = codex_home.resolve().relative_to(project_path.resolve()) + except Exception: + return + + codex_prefix = codex_home_relative.as_posix() + if codex_prefix == ".": + return + ignore_entries = [ + f"{codex_prefix}/*.json", + f"{codex_prefix}/*.jsonl", + f"{codex_prefix}/*.toml", + f"{codex_prefix}/log", + f"{codex_prefix}/sessions", + ] + _ensure_gitignore_entries(project_path, ignore_entries) + + except Exception as exc: + if tracker: + tracker.error("codex-prompts", str(exc)) + else: + console.print(f"[yellow]Warning: could not sync Codex prompts ({exc})[/yellow]") + + @app.command() def init( project_name: str = typer.Argument(None, help="Name for your new project directory (optional if using --here)"), @@ -1125,6 +1243,7 @@ def init( if selected_ai == "codex": tracker.add("commands", "Ensure workspace commands") + tracker.add("codex-prompts", "Sync Codex prompts") # Use transient so live tree is replaced by the final static render (avoids duplicate output) with Live(tracker.render(), console=console, refresh_per_second=8, transient=True) as live: @@ -1140,6 +1259,7 @@ def init( # Ensure /commands directory for Codex CLI workspaces only if selected_ai == "codex": ensure_workspace_commands(project_path, tracker=tracker) + sync_codex_prompts(project_path, tracker=tracker) # Ensure scripts are executable (POSIX) ensure_executable_scripts(project_path, tracker=tracker) diff --git a/templates/commands/specify.md b/templates/commands/specify.md index 41b8f6f..2ea4f78 100644 --- a/templates/commands/specify.md +++ b/templates/commands/specify.md @@ -5,7 +5,9 @@ scripts: ps: scripts/powershell/create-new-feature.ps1 -Json "{ARGS}" --- -Given the feature description provided as an argument, do this: +The text the user typed after `/specify` in the triggering message **is** the feature description. Assume you always have it available in this conversation even if `{ARGS}` appears literally below. Do not ask the user to repeat it unless they provided a truly empty command. + +Given that feature description, do this: 1. Run the script `{SCRIPT}` from repo root and parse its JSON output for BRANCH_NAME and SPEC_FILE. All file paths must be absolute. 2. Load `templates/spec-template.md` to understand required sections.