Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions .gitattributes
Original file line number Diff line number Diff line change
@@ -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
6 changes: 1 addition & 5 deletions packages/cli-js/src/commands/doctor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -498,11 +498,7 @@ export async function runDoctorAi(args: string[]): Promise<void> {
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;
Expand Down
23 changes: 18 additions & 5 deletions packages/cli-js/src/providers/codex.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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).
*/
Expand Down Expand Up @@ -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)) {
Expand Down Expand Up @@ -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");

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Guard against stdin EPIPE in streaming Codex exec

In the non-Windows streaming path, the code writes to child.stdin without handling stdin stream errors. If codex exec - exits before consuming stdin (for example due to startup/auth/usage failures), Node can emit write EPIPE on that stream and terminate the process with an unhandled error, so runPlan/runImplement/doctor streaming crash instead of surfacing the Codex stderr message.

Useful? React with 👍 / 👎.

child.stdin.end();
child.stdout?.on("data", handleStdout);
child.stderr?.on("data", handleStderr);
child.on("close", (code) => {
Expand Down
6 changes: 1 addition & 5 deletions packages/cli-py/planforge/commands/doctor.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
11 changes: 9 additions & 2 deletions packages/cli-py/planforge/providers/codex.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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,))
Expand Down
17 changes: 12 additions & 5 deletions scripts/validate_cursor_assets.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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}`);
}
}
Expand Down
4 changes: 2 additions & 2 deletions templates/doctor/prompts.json
Original file line number Diff line number Diff line change
@@ -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."
}
Loading