diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..9a1081f --- /dev/null +++ b/.gitattributes @@ -0,0 +1,11 @@ +# Shell scripts must use LF so they run on Mac/Linux (e.g. .cursor/skills/*/scripts/*.sh). +*.sh text eol=lf +# Cursor rules/skills: keep LF for cross-platform and CI (template–runtime comparison, script execution). +.cursor/rules/*.mdc text eol=lf +.cursor/skills/**/*.md text eol=lf +.cursor/skills/**/*.py text eol=lf +.cursor/skills/**/*.ps1 text eol=lf +templates/cursor/rules/*.mdc text eol=lf +templates/cursor/skills/**/*.md text eol=lf +templates/cursor/skills/**/*.py text eol=lf +templates/cursor/skills/**/*.ps1 text eol=lf diff --git a/packages/cli-js/src/commands/doctor.ts b/packages/cli-js/src/commands/doctor.ts index eb9b420..17a1d7f 100644 --- a/packages/cli-js/src/commands/doctor.ts +++ b/packages/cli-js/src/commands/doctor.ts @@ -498,11 +498,7 @@ export async function runDoctorAi(args: string[]): Promise { throw new Error(`Missing or invalid template: ${promptsPath}. Run from repo root or ensure templates exist.`); } - const workflowContent = loadWorkflowMdc(projectRoot); - const systemPrompt = - workflowContent + - "\n\nAnswer in one sentence only: what command or action you will take for the user request. Do not run anything."; - + const systemPrompt = loadWorkflowMdc(projectRoot); let exitCode = 0; let selectedPlanner: DoctorAiModelOption; let selectedImplementer: DoctorAiModelOption; diff --git a/packages/cli-js/src/providers/codex.ts b/packages/cli-js/src/providers/codex.ts index 5cdb92f..5522af3 100644 --- a/packages/cli-js/src/providers/codex.ts +++ b/packages/cli-js/src/providers/codex.ts @@ -103,8 +103,9 @@ function looksLikePlan(stdout: string): boolean { } /** - * Run "codex exec" with the given prompt. On Windows uses temp file + PowerShell to avoid - * EINVAL from spawning .cmd directly (CVE-2024-27980) and to avoid shell splitting long args. + * Run "codex exec" with the given prompt. On all platforms the prompt is passed via stdin + * (codex exec -) to avoid argv length limits and CLI parsing of special characters (e.g. ---). + * On Windows uses temp file + PowerShell to avoid EINVAL from spawning .cmd (CVE-2024-27980). * When allowPlanFallback is true, non-zero exit is still treated as success if stdout looks like a plan * (used only for runPlan; runImplement must not treat non-zero as success). */ @@ -144,7 +145,7 @@ function runCodexExec(fullPrompt: string, cwd: string, allowPlanFallback = false } } - const result = spawnSync(exe, ["exec", fullPrompt], { ...opts, shell: false }); + const result = spawnSync(exe, ["exec", "-"], { ...opts, input: fullPrompt, shell: false }); const out = (result.stdout ?? "").trim(); if (result.status !== 0) { if (allowPlanFallback && result.status === 1 && looksLikePlan(out)) { @@ -260,11 +261,23 @@ function runCodexExecStreaming( return; } - const child = spawn(exe, ["exec", fullPrompt], { + const child = spawn(exe, ["exec", "-"], { ...opts, - stdio: ["ignore", "pipe", "pipe"], + stdio: ["pipe", "pipe", "pipe"], }); const clearTimeoutRef = scheduleTimeout(child); + child.stdin.on("error", (err: NodeJS.ErrnoException) => { + if (err.code === "EPIPE") { + return; + } + clearTimeoutRef(); + if (!settled) { + settled = true; + reject(err); + } + }); + child.stdin.write(fullPrompt, "utf-8"); + child.stdin.end(); child.stdout?.on("data", handleStdout); child.stderr?.on("data", handleStderr); child.on("close", (code) => { diff --git a/packages/cli-py/planforge/commands/doctor.py b/packages/cli-py/planforge/commands/doctor.py index 84250be..2e62545 100644 --- a/packages/cli-py/planforge/commands/doctor.py +++ b/packages/cli-py/planforge/commands/doctor.py @@ -454,11 +454,7 @@ def run_doctor_ai(args: list[str]) -> None: f"Missing or invalid template: {prompts_path}. Run from repo root or ensure templates exist." ) from e - workflow_content = _load_workflow_mdc(project_root) - system_prompt = ( - workflow_content - + "\n\nAnswer in one sentence only: what command or action you will take for the user request. Do not run anything." - ) + system_prompt = _load_workflow_mdc(project_root) is_interactive = sys.stdin.isatty() and not (provider_arg and model_arg) use_planner_implementer_selection = is_interactive and catalog is not None diff --git a/packages/cli-py/planforge/providers/codex.py b/packages/cli-py/planforge/providers/codex.py index f55ba00..8d0542e 100644 --- a/packages/cli-py/planforge/providers/codex.py +++ b/packages/cli-py/planforge/providers/codex.py @@ -117,8 +117,9 @@ def _run_codex_exec(full_prompt: str, cwd: str, *, allow_plan_fallback: bool = F except OSError: pass result = subprocess.run( - [exe, "exec", full_prompt], + [exe, "exec", "-"], cwd=cwd, + input=full_prompt, capture_output=True, text=True, timeout=300, @@ -193,12 +194,18 @@ def read_stderr(proc: subprocess.Popen) -> None: raise else: proc = subprocess.Popen( - [exe, "exec", full_prompt], + [exe, "exec", "-"], cwd=cwd, + stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, ) + try: + proc.stdin.write(full_prompt) + proc.stdin.close() + except BrokenPipeError: + pass temp_path = None t_out = threading.Thread(target=read_stdout, args=(proc,)) diff --git a/scripts/validate_cursor_assets.mjs b/scripts/validate_cursor_assets.mjs index 0eb7ca1..3024990 100644 --- a/scripts/validate_cursor_assets.mjs +++ b/scripts/validate_cursor_assets.mjs @@ -36,21 +36,28 @@ function requireContains(filePath, text, required) { } function parseFrontmatter(filePath, text) { - if (!text.startsWith("---\n")) { + const normalized = text.replace(/\r\n/g, "\n"); + if (!normalized.startsWith("---\n")) { fail(`${filePath} must start with YAML frontmatter`); } - const end = text.indexOf("\n---\n", 4); + const end = normalized.indexOf("\n---\n", 4); if (end === -1) { fail(`${filePath} has invalid frontmatter delimiter`); } return { - frontmatter: text.slice(4, end), - body: text.slice(end + 5), + frontmatter: normalized.slice(4, end), + body: normalized.slice(end + 5), }; } +function normalizeLineEndings(text) { + return text.replace(/\r\n/g, "\n").replace(/\r/g, "\n"); +} + function ensureEqual(aPath, aText, bPath, bText) { - if (aText !== bText) { + const a = normalizeLineEndings(aText); + const b = normalizeLineEndings(bText); + if (a !== b) { fail(`template/runtime mismatch: ${aPath} != ${bPath}`); } } diff --git a/templates/doctor/prompts.json b/templates/doctor/prompts.json index b3e2e3e..f2b65c8 100644 --- a/templates/doctor/prompts.json +++ b/templates/doctor/prompts.json @@ -1,5 +1,5 @@ { - "tc1PlanRequest": "Give me a plan for this project.", + "tc1PlanRequest": "I want a plan for a script that prints \"Hello world\".", "tc2ImplementRequest": "Implement according to the plan.", - "tc3SlashPWithImplementationStyleContent": "/p planforge init currently forces init when Claude is installed; change it to ask the user (y/n) whether to run Claude init." + "tc3SlashPWithImplementationStyleContent": "The user invoked the plan command (/p) with this request: planforge init currently forces init when Claude is installed; change it to ask the user (y/n) whether to run Claude init." }