diff --git a/.cursor/rules/planforge-workflow.mdc b/.cursor/rules/planforge-workflow.mdc index 8a5e01d..f29435e 100644 --- a/.cursor/rules/planforge-workflow.mdc +++ b/.cursor/rules/planforge-workflow.mdc @@ -6,7 +6,7 @@ alwaysApply: true # PlanForge Workflow -- **When the user uses /p or asks for a plan/design/structure:** +- **When the user uses /p:** Using /p alone obliges you to run plan. Do not skip running `planforge plan` (or the plan script) by reclassifying the message as an "implementation request" based on its content. Treat the user's message as the goal and run the plan command first. Run **exactly one** of these with **no** `cd`, **no** `&&`, and no other prefix (terminal is already in workspace root): `planforge plan ""` or `.cursor/skills/p/scripts/run_plan.sh` (mac/Linux) or `.cursor/skills/p/scripts/run_plan.ps1` (Windows). Run it in the foreground so output streams in the agent terminal (sandbox). Do not write any plan/design output directly as a fallback. After the command succeeds, read `.cursor/plans/YYYY-MM-DD/{HHMM}-.plan.md` and summarize/reference it. If command execution fails, return the error and suggested fixes only. -- **When the user uses /i or asks for implementation:** +- **When the user uses /i:** Run **exactly one** command with **no** `cd`, **no** `&&`, and no other prefix (terminal is already in workspace root): `planforge implement ""` or `.cursor/skills/i/scripts/run_implement.sh` (mac/Linux) or `.cursor/skills/i/scripts/run_implement.ps1` (Windows). Run it in the foreground so output streams in the agent terminal (sandbox). Do not produce direct implementation output without running the command. Prefer the active plan in `.cursor/plans/index.json` (`activePlan`) or the latest `.plan.md`. After the command completes, on success summarize the result or suggest next steps; on failure return the error and suggested fixes only. If the command cannot be started, return the error and suggested fixes only. diff --git a/.cursor/rules/pr-writing.mdc b/.cursor/rules/pr-writing.mdc new file mode 100644 index 0000000..25cf09b --- /dev/null +++ b/.cursor/rules/pr-writing.mdc @@ -0,0 +1,49 @@ +--- +description: When the user asks for a PR, output markdown based on the template and session context +alwaysApply: false +--- + +# PR Writing Rules + +When the user **requests a PR** (e.g. "Write a PR", "Create a PR"), follow the steps below. + +## 1. Use the Template + +- **File**: Read `.github/PULL_REQUEST_TEMPLATE.md` and follow its structure. +- Keep the template sections (Purpose, Description, How to test, Review Requirement, Additional Info) and replace placeholders (`_…_`) with actual content. + +## 2. Writing Content + +- **Session context**: Fill each section from the requests, changes, and decisions made in the current session. +- **Purpose**: State the goal of the PR in one or two clear sentences. +- **Description**: List modified/added files, a concise summary of changes, and any preserved behavior. Use lists and tables where helpful. +- **How to test**: Numbered steps for the reviewer to verify the changes locally or in the browser. +- **Review Requirement**: What the reviewer should pay extra attention to. +- **Additional Info**: Issue reference, **link to the related Notion page** (required), and any other links or notes. + +## 2.5 Notion Link (Required) + +- **Always** include a **link to the related Notion page** in the PR body. +- **Place**: In the Additional Info section as "Related Notion: [Page title](URL)". You may also put it at the top of Description. +- **URL**: Use the Notion URL shared in the conversation if available. Otherwise add a placeholder (e.g. `Related Notion: (add the Notion page URL for this work)`) and ask the user to fill it. + +## 3. PR Title (Required) + +- The PR title **must** include the **issue/PR number**. Use **`[NO-ISSUE]`** if the branch name does not contain one. +- **Extract the number from the current branch name**. Run `git branch --show-current` when writing the PR and use the number between slashes or hyphens (e.g. TSK-112, 110). + - e.g. `feature/TSK-112-add-feature` → `TSK-112` + - e.g. `fix/110-bug` → `110` or `TSK-110` (per project convention) + - e.g. `feature/add-something` (no number) → `[NO-ISSUE]` +- **Format**: `[number] Title` or `[NO-ISSUE] Title` — **Do not use commit-style prefixes** (feat:, refactor:, fix:). Use a short descriptive title only. + - e.g. `[TSK-112] Remove character IP and switch to logo and icon UI` + - e.g. `[NO-ISSUE] Documentation cleanup` + +## 4. Output Format + +- Output **markdown** so the user can **copy and paste** it into the GitHub PR body. +- **Always provide a PR title example** that includes the number from the current branch (or `[NO-ISSUE]`). +- Wrap the body in a markdown code block (```markdown ... ```) for easy copying. + +## 5. Reference + +- Follow `code-comments.mdc` for code and comment style. diff --git a/.cursor/skills/i/SKILL.md b/.cursor/skills/i/SKILL.md index 5b65679..7279ca5 100644 --- a/.cursor/skills/i/SKILL.md +++ b/.cursor/skills/i/SKILL.md @@ -12,7 +12,7 @@ You must execute the PlanForge command path for every `/i` request. Do not imple **When the user invokes /i with a prompt (e.g. "/i implement the login API"):** 1. **Summarize the conversation** - Summarize the current chat (goals, decisions, constraints, relevant files or tech) in a short paragraph and write/update a markdown file in the project's **context directory** (`.cursor/contexts`). Use a dated subdirectory and `HHMM-...` filename so plan/implement will read it, for example `.cursor/contexts/2026-03-10/1430-implement.md`. Keep it short and overwrite or append as needed. -2. **Run the implement script in the foreground (required)** - Run **one** command only: no `cd`, no `&&`. Terminal is already in workspace root. Use `.cursor/skills/i/scripts/run_implement.ps1 ""` on Windows or `.cursor/skills/i/scripts/run_implement.sh` on mac/Linux. Run it **in the foreground** so that output streams in the Cursor chat sandbox terminal; do not run in the background. Never write final code as a substitute for script execution. The command runs `planforge implement` using `planforge.json`. +2. **Run the implement script in the foreground (required)** - **Before** running the command, output a one-line advisory in the **same language as the user's message** (e.g. if the user wrote in Korean, write in Korean; if in English, in English). Example: "Expand the sandbox and check the output logs." Then run **one** command only: no `cd`, no `&&`. Terminal is already in workspace root. Use `.cursor/skills/i/scripts/run_implement.ps1 ""` on Windows or `.cursor/skills/i/scripts/run_implement.sh` on mac/Linux. Run it **in the foreground** so that output streams in the Cursor chat sandbox terminal; do not run in the background. Never write final code as a substitute for script execution. The command runs `planforge implement` using `planforge.json`. 3. **After it completes** - On success, summarize the result or suggest next steps. On failure, report the error output and suggest fixes. Do not produce implementation output in chat without having run the command. If script execution is blocked or fails, stop and return an error-focused response. Do not provide a substitute direct implementation. diff --git a/.cursor/skills/p/SKILL.md b/.cursor/skills/p/SKILL.md index 6b00397..1522e74 100644 --- a/.cursor/skills/p/SKILL.md +++ b/.cursor/skills/p/SKILL.md @@ -27,7 +27,7 @@ You must execute the PlanForge command path for every `/p` request. Do not draft ``` If you fetched URL content in step 0, append it as a separate section (e.g. `## Fetched URLs` or per-URL headings) in the **same** file. Create the dated subdirectory if it does not exist. -2. **Run the plan script in the foreground (required)** - Run **one** command only: no `cd`, no `&&` (PowerShell does not support `&&`). Terminal is already in workspace root. Use `.cursor/skills/p/scripts/run_plan.ps1 ""` on Windows or `.cursor/skills/p/scripts/run_plan.sh` on mac/Linux, or `planforge plan ""`. Run it **in the foreground** so that output streams in the Cursor chat sandbox terminal; do not run in the background. Pass the **same slug** so the plan output filename matches the context file: invoke as `planforge plan "" --slug ` (or ensure the script forwards `--slug `). The command generates `.cursor/plans/YYYY-MM-DD/{HHMM}-.plan.md` so that context file `HHMM-.md` and plan file `HHMM-.plan.md` use the same slug. Never create `.plan.md` content directly in chat. +2. **Run the plan script in the foreground (required)** - **Before** running the command, output a one-line advisory in the **same language as the user's message** (e.g. if the user wrote in Korean, write in Korean; if in English, in English). Example: "This may take a while. Please wait." Then run **one** command only: no `cd`, no `&&` (PowerShell does not support `&&`). Terminal is already in workspace root. Use `.cursor/skills/p/scripts/run_plan.ps1 ""` on Windows or `.cursor/skills/p/scripts/run_plan.sh` on mac/Linux, or `planforge plan ""`. Run it **in the foreground** so that output streams in the Cursor chat sandbox terminal; do not run in the background. Pass the **same slug** so the plan output filename matches the context file: invoke as `planforge plan "" --slug ` (or ensure the script forwards `--slug `). The command generates `.cursor/plans/YYYY-MM-DD/{HHMM}-.plan.md` so that context file `HHMM-.md` and plan file `HHMM-.plan.md` use the same slug. Never create `.plan.md` content directly in chat. 3. **After it completes** - Read the generated `.plan.md` file and summarize/reference it in your reply. Do not start implementation. If execution fails, report the error output and suggest concrete fixes (for example `planforge init` or installing the configured provider CLI). 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/.gitignore b/.gitignore index 9e9d696..95a96c2 100644 --- a/.gitignore +++ b/.gitignore @@ -23,7 +23,6 @@ planforge.json .cursor/contexts .cursor/rules/code-comments.mdc .cursor/rules/notion.mdc -.cursor/rules/pr-writing.mdc # logs *.log diff --git a/packages/cli-js/package.json b/packages/cli-js/package.json index 3ee5bc8..2390919 100644 --- a/packages/cli-js/package.json +++ b/packages/cli-js/package.json @@ -1,6 +1,6 @@ { "name": "planforge", - "version": "0.1.7", + "version": "0.1.8", "type": "module", "description": "PlanForge CLI - Bring your own AI to Cursor", "keywords": ["cursor", "claude", "codex", "ai", "cli", "planning", "planforge"], diff --git a/packages/cli-js/src/commands/doctor.ts b/packages/cli-js/src/commands/doctor.ts index 1c9075d..66c76f9 100644 --- a/packages/cli-js/src/commands/doctor.ts +++ b/packages/cli-js/src/commands/doctor.ts @@ -4,6 +4,7 @@ import fs from "fs-extra"; import readline from "readline"; +import { createSpinner } from "../utils/spinner.js"; import { resolve } from "path"; import { getProjectRoot, @@ -12,12 +13,12 @@ import { getTemplatesRoot, } from "../utils/paths.js"; import { printCurrentAiConfig, selectFromList } from "../utils/tui.js"; -import { loadConfig, getDefaultDoctorAiConfig } from "../config/load.js"; +import { loadConfig } from "../config/load.js"; import type { PlanForgeConfig } from "../config/types.js"; -import { checkClaude, listModelsClaude } from "../providers/claude.js"; -import { checkCodex, listModelsCodex } from "../providers/codex.js"; +import { checkClaude } from "../providers/claude.js"; +import { checkCodex } from "../providers/codex.js"; import { getOneTurnRunner } from "../providers/registry.js"; -import { loadModelsCatalog, type ModelsCatalog } from "./model.js"; +import { loadModelsCatalog, runModelTui, type ModelsCatalog } from "./model.js"; const URL_TEST_URL = "https://httpbin.org/get"; const URL_TEST_TIMEOUT_MS = 5_000; @@ -74,20 +75,12 @@ function isDatedPlanFileName(name: string): boolean { const DOCTOR_MODE_STATIC = "static"; const DOCTOR_MODE_AI = "ai"; -/** When doctor is run without subcommand: TTY shows Doctor AI config (default) then mode selection (static/ai/Quit); non-TTY runs static. */ +/** When doctor is run without subcommand: TTY shows mode selection (static/ai/Quit) first; non-TTY runs static. */ export async function runDoctorModeSelect(): Promise { if (!process.stdin.isTTY) { await runDoctor([]); return; } - const hasClaude = checkClaude(); - const hasCodex = checkCodex(); - try { - const doctorAiConfig = getDefaultDoctorAiConfig(hasClaude, hasCodex); - printCurrentAiConfig(doctorAiConfig, "Doctor AI config (default)"); - } catch { - // skip config block if templates missing - } const chosen = await selectFromList( [ { label: "static – Check environment and providers", value: DOCTOR_MODE_STATIC }, @@ -276,23 +269,7 @@ async function runStreamingDoctorTc( const reset = "\x1b[0m"; const passColor = "\x1b[92m"; const failColor = "\x1b[31m"; - const spinnerFrames = ["|", "/", "-", "\\"]; - let spinnerInterval: ReturnType | null = null; - const startSpinner = () => { - let frameIdx = 0; - spinnerInterval = setInterval(() => { - readline.clearLine(process.stdout, 0); - readline.cursorTo(process.stdout, 0); - process.stdout.write(` ${dim}response:${reset} ${spinnerFrames[frameIdx % spinnerFrames.length]}`); - frameIdx++; - }, 80); - }; - const stopSpinner = () => { - if (spinnerInterval !== null) { - clearInterval(spinnerInterval); - spinnerInterval = null; - } - }; + const spinner = createSpinner({ prefix: " response: " }); const render = (suffix = "") => { const normalized = response.replace(/\s+/g, " ").trim(); readline.clearLine(process.stdout, 0); @@ -304,12 +281,12 @@ async function runStreamingDoctorTc( process.stdout.write(` ${dim}response:${reset} `); try { - startSpinner(); + spinner.start(); const finalResponse = await runner.streamOneTurn( systemPrompt, userMessage, (chunk) => { - if (response.length === 0 && chunk.length > 0) stopSpinner(); + if (response.length === 0 && chunk.length > 0) spinner.stop(); response += chunk; if (!passShown && expectedKeywords.some((keyword) => response.includes(keyword))) { passShown = true; @@ -322,14 +299,14 @@ async function runStreamingDoctorTc( }, { cwd, model } ); - stopSpinner(); + spinner.stop(); response = finalResponse; const passed = expectedKeywords.some((keyword) => response.includes(keyword)); render(passed ? ` ${passColor}\u2713 PASS${reset}` : ` ${failColor}\u2717 FAIL${reset}`); process.stdout.write("\n"); return { passed, response }; } catch (err) { - stopSpinner(); + spinner.stop(); render(` ${failColor}\u2717 FAIL${reset}`); process.stdout.write("\n"); return { @@ -354,83 +331,10 @@ function loadWorkflowMdc(projectRoot: string): string { ); } -function buildModelListFromConfig(config: PlanForgeConfig, hasClaude: boolean, hasCodex: boolean): DoctorAiModelOption[] { - const seen = new Set(); - const options: DoctorAiModelOption[] = []; - const recommendedKey = config.planner.provider + "|" + config.planner.model; - for (const role of ["planner", "implementer"] as const) { - const r = config[role]; - const key = r.provider + "|" + r.model; - if (seen.has(key)) continue; - seen.add(key); - if (r.provider === "claude" && !hasClaude) continue; - if (r.provider === "codex" && !hasCodex) continue; - options.push({ - provider: r.provider, - model: r.model, - recommended: key === recommendedKey, - }); - } - return options; -} - -/** Build flat provider/model options from models.json catalog for available providers only. */ -function buildOptionsFromCatalog(catalog: ModelsCatalog, hasClaude: boolean, hasCodex: boolean): DoctorAiModelOption[] { - const options: DoctorAiModelOption[] = []; - const seen = new Set(); - for (const providerId of Object.keys(catalog.providers)) { - if (providerId === "claude" && !hasClaude) continue; - if (providerId === "codex" && !hasCodex) continue; - const prov = catalog.providers[providerId]; - const models = prov?.models ?? []; - for (const m of models) { - const key = providerId + "|" + m.id; - if (seen.has(key)) continue; - seen.add(key); - options.push({ provider: providerId, model: m.id, recommended: false }); - } - } - return options; -} - -/** - * Interactive: select provider then model (last model = recommended). Returns { provider, model } or null if canceled. - */ -async function selectProviderAndModel( - catalog: ModelsCatalog, - hasClaude: boolean, - hasCodex: boolean, - roleLabel: string -): Promise<{ provider: string; model: string } | null> { - const providerIds = Object.keys(catalog.providers).filter( - (p) => (p === "claude" && hasClaude) || (p === "codex" && hasCodex) - ); - if (providerIds.length === 0) return null; - - for (;;) { - const providerId = await selectFromList( - providerIds.map((id) => ({ - label: `${catalog.providers[id]?.name ?? id} (${id})`, - value: id, - })), - `Select ${roleLabel} [Up/Down] Enter to confirm` - ); - if (providerId === null) return null; - - const prov = catalog.providers[providerId]; - const models = prov?.models ?? []; - if (models.length === 0) continue; - - const modelId = await selectFromList( - models.map((model, index) => ({ - label: `${model.label} (${model.id})${index === models.length - 1 ? " (recommended)" : ""}`, - value: model.id, - })), - "[Up/Down] model Enter to confirm (last = recommended)" - ); - if (modelId === null) continue; - return { provider: providerId, model: modelId }; - } +/** Validate that provider+model exists in catalog (for --provider --model). */ +function isValidProviderModel(catalog: ModelsCatalog, provider: string, model: string): boolean { + const prov = catalog.providers[provider]; + return Boolean(prov?.models?.some((m) => m.id === model)); } export async function runDoctorAi(args: string[]): Promise { @@ -447,54 +351,18 @@ export async function runDoctorAi(args: string[]): Promise { process.exit(1); } - let catalog: ModelsCatalog | null = null; + let catalog: ModelsCatalog; try { catalog = loadModelsCatalog(); - } catch { - catalog = null; - } - - let options: DoctorAiModelOption[]; - if (catalog !== null) { - options = buildOptionsFromCatalog(catalog, hasClaude, hasCodex); - } else { - const claudeModels = await listModelsClaude(); - const codexModels = await listModelsCodex(); - if (claudeModels !== null || codexModels !== null) { - options = []; - if (claudeModels !== null && hasClaude) { - for (const model of claudeModels) { - options.push({ - provider: "claude", - model, - recommended: config.planner.provider === "claude" && config.planner.model === model, - }); - } - } - if (codexModels !== null && hasCodex) { - for (const model of codexModels) { - options.push({ - provider: "codex", - model, - recommended: config.planner.provider === "codex" && config.planner.model === model, - }); - } - } - if (options.length === 0) options = buildModelListFromConfig(config, hasClaude, hasCodex); - } else { - options = buildModelListFromConfig(config, hasClaude, hasCodex); - } - } - - if (options.length === 0) { - console.error("No AI provider available. Install Claude or Codex CLI and run planforge init."); + } catch (e) { + console.error((e as Error).message); + console.error("doctor ai uses the same model catalog as planforge model. Run pnpm run build in cli-js or use planforge model."); process.exit(1); } const providerArg = args.includes("--provider") ? args[args.indexOf("--provider") + 1] : undefined; const modelArg = args.includes("--model") ? args[args.indexOf("--model") + 1] : undefined; const isInteractive = process.stdin.isTTY && !providerArg && !modelArg; - const usePlannerImplementerSelection = isInteractive && catalog !== null; const promptsPath = resolve(getTemplatesRoot(), "doctor", "prompts.json"); if (!fs.existsSync(promptsPath)) { @@ -513,32 +381,30 @@ 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; if (providerArg && modelArg) { - const match = options.find((o) => o.provider === providerArg && o.model === modelArg); - if (!match) { - console.error(`No matching option for --provider ${providerArg} --model ${modelArg}`); + if (!isValidProviderModel(catalog, providerArg, modelArg)) { + console.error(`No matching option for --provider ${providerArg} --model ${modelArg}. Check models.json catalog.`); process.exit(1); } - selectedPlanner = selectedImplementer = match; + selectedPlanner = selectedImplementer = { + provider: providerArg, + model: modelArg, + recommended: false, + }; } else if (isInteractive) { - const doctorAiDefault = getDefaultDoctorAiConfig(hasClaude, hasCodex); selectedPlanner = { - provider: doctorAiDefault.planner.provider, - model: doctorAiDefault.planner.model, + provider: config.planner.provider, + model: config.planner.model, recommended: false, }; selectedImplementer = { - provider: doctorAiDefault.implementer.provider, - model: doctorAiDefault.implementer.model, + provider: config.implementer.provider, + model: config.implementer.model, recommended: false, }; for (;;) { @@ -547,12 +413,12 @@ export async function runDoctorAi(args: string[]): Promise { planner: { provider: selectedPlanner.provider, model: selectedPlanner.model, - ...(doctorAiDefault.planner.effort != null && { effort: doctorAiDefault.planner.effort }), + ...(config.planner.effort != null && { effort: config.planner.effort }), }, implementer: { provider: selectedImplementer.provider, model: selectedImplementer.model, - ...(doctorAiDefault.implementer.reasoning != null && { reasoning: doctorAiDefault.implementer.reasoning }), + ...(config.implementer.reasoning != null && { reasoning: config.implementer.reasoning }), }, }, "Doctor AI config" @@ -568,44 +434,63 @@ export async function runDoctorAi(args: string[]): Promise { if (action === null) process.exit(exitCode); if (action === "run") break; - if (usePlannerImplementerSelection) { - const firstRole = await selectFromList( - [ - { label: "planner", value: "planner" as const }, - { label: "implementer", value: "implementer" as const }, - ], - "Mode [Up/Down] Enter to confirm" - ); - if (firstRole === null) process.exit(exitCode); - const secondRole = firstRole === "planner" ? "implementer" : "planner"; - - const firstSel = await selectProviderAndModel(catalog!, hasClaude, hasCodex, firstRole); - if (firstSel === null) process.exit(exitCode); - const secondSel = await selectProviderAndModel(catalog!, hasClaude, hasCodex, secondRole); - if (secondSel === null) process.exit(exitCode); - - selectedPlanner = - firstRole === "planner" - ? { provider: firstSel.provider, model: firstSel.model, recommended: false } - : { provider: secondSel.provider, model: secondSel.model, recommended: false }; - selectedImplementer = - firstRole === "implementer" - ? { provider: firstSel.provider, model: firstSel.model, recommended: false } - : { provider: secondSel.provider, model: secondSel.model, recommended: false }; - } else { - const selected = await selectFromList( - options.map((option) => ({ - label: `${option.provider} (${option.model})`, - value: option, - })), - "Select AI for workflow test [Up/Down] Enter to confirm" - ); - if (selected === null) process.exit(exitCode); - selectedPlanner = selectedImplementer = selected; - } + const firstRole = await selectFromList( + [ + { label: "planner", value: "planner" as const }, + { label: "implementer", value: "implementer" as const }, + ], + "Role to change [Up/Down] Enter to confirm" + ); + if (firstRole === null) process.exit(exitCode); + const secondRole = firstRole === "planner" ? "implementer" : "planner"; + + const firstResult = await runModelTui(catalog, hasClaude, hasCodex, config, { + preselectedMode: firstRole, + }); + if (firstResult === null) process.exit(exitCode); + const secondResult = await runModelTui(catalog, hasClaude, hasCodex, config, { + preselectedMode: secondRole, + }); + if (secondResult === null) process.exit(exitCode); + + const plannerResult = firstRole === "planner" ? firstResult : secondResult; + const implementerResult = firstRole === "implementer" ? firstResult : secondResult; + selectedPlanner = { + provider: plannerResult.config.provider, + model: plannerResult.config.model, + recommended: false, + }; + selectedImplementer = { + provider: implementerResult.config.provider, + model: implementerResult.config.model, + recommended: false, + }; + config.planner = { + ...config.planner, + provider: plannerResult.config.provider, + model: plannerResult.config.model, + ...(plannerResult.config.effort != null && { effort: plannerResult.config.effort }), + ...(plannerResult.config.reasoning != null && { reasoning: plannerResult.config.reasoning }), + }; + config.implementer = { + ...config.implementer, + provider: implementerResult.config.provider, + model: implementerResult.config.model, + ...(implementerResult.config.effort != null && { effort: implementerResult.config.effort }), + ...(implementerResult.config.reasoning != null && { reasoning: implementerResult.config.reasoning }), + }; } } else { - selectedPlanner = selectedImplementer = options[0]; + selectedPlanner = { + provider: config.planner.provider, + model: config.planner.model, + recommended: false, + }; + selectedImplementer = { + provider: config.implementer.provider, + model: config.implementer.model, + recommended: false, + }; } const plannerRunner = getOneTurnRunner(selectedPlanner.provider); diff --git a/packages/cli-js/src/commands/implement.ts b/packages/cli-js/src/commands/implement.ts index 0b6db3b..048ef37 100644 --- a/packages/cli-js/src/commands/implement.ts +++ b/packages/cli-js/src/commands/implement.ts @@ -12,6 +12,7 @@ import { parseFilesFromPlan } from "../utils/plan-files.js"; import { getProjectContext } from "../utils/project-context.js"; import { loadMergedContext } from "../utils/context.js"; import { loadConfig } from "../config/load.js"; +import { resolveImplementerStreamTimeoutSec } from "../config/timeout.js"; import { getImplementerRunner } from "../providers/registry.js"; const MAX_CODE_CONTEXT_CHARS = 12000; @@ -179,6 +180,7 @@ export async function runImplement(args: string[], opts?: ImplementCliOpts): Pro const recentCommitsPerFile = filesToChange.length > 0 ? buildRecentCommitsForFiles(projectRoot, filesToChange) : undefined; + const streamTimeoutSec = resolveImplementerStreamTimeoutSec(config.implementer); try { const result = await runner.runImplement(prompt, { cwd: projectRoot, @@ -189,6 +191,7 @@ export async function runImplement(args: string[], opts?: ImplementCliOpts): Pro projectContext, projectContextSource, recentCommitsPerFile, + streamTimeoutMs: streamTimeoutSec === 0 ? 0 : streamTimeoutSec * 1000, }); const extracted = extractFilesFromOutput(result); const root = resolve(projectRoot); diff --git a/packages/cli-js/src/commands/model.ts b/packages/cli-js/src/commands/model.ts index 1023db6..4c38523 100644 --- a/packages/cli-js/src/commands/model.ts +++ b/packages/cli-js/src/commands/model.ts @@ -38,20 +38,31 @@ export function loadModelsCatalog(): ModelsCatalog { return fs.readJsonSync(filePath) as ModelsCatalog; } -/** Exported for planforge model: interactive mode => provider => model selection with effort/reasoning. */ +export interface RunModelTuiOptions { + /** When set, skip mode selection and use this mode (e.g. doctor ai uses planner/implementer). */ + preselectedMode?: string; +} + +/** Exported for planforge model and doctor ai: interactive mode => provider => model selection with effort/reasoning. */ export async function runModelTui( catalog: ModelsCatalog, hasClaude: boolean, hasCodex: boolean, - defaultConfig?: PlanForgeConfig + defaultConfig?: PlanForgeConfig, + options?: RunModelTuiOptions ): Promise<{ mode: string; config: Record } | null> { const { modes, modeProviders, providers } = catalog; - const mode = await selectFromList( - modes.map((m) => ({ label: m, value: m })), - "Mode: [Up/Down] Enter to confirm" - ); - if (mode === null) return null; + let mode: string | null; + if (options?.preselectedMode != null && modes.includes(options.preselectedMode)) { + mode = options.preselectedMode; + } else { + mode = await selectFromList( + modes.map((m) => ({ label: m, value: m })), + "Mode: [Up/Down] Enter to confirm" + ); + if (mode === null) return null; + } const providerIds = modeProviders[mode] ?? Object.keys(providers); const available = providerIds.filter( diff --git a/packages/cli-js/src/commands/plan.ts b/packages/cli-js/src/commands/plan.ts index daeba33..74295f6 100644 --- a/packages/cli-js/src/commands/plan.ts +++ b/packages/cli-js/src/commands/plan.ts @@ -12,7 +12,9 @@ import { getProjectContext } from "../utils/project-context.js"; import { loadMergedContext } from "../utils/context.js"; import { fetchUrlsContext } from "../utils/url-fetch.js"; import { loadConfig } from "../config/load.js"; +import { resolvePlannerStreamTimeoutSec } from "../config/timeout.js"; import { getPlannerRunner } from "../providers/registry.js"; +import { createSpinner } from "../utils/spinner.js"; /** Characters disallowed in filenames on Windows / macOS / Linux */ const FILENAME_UNSAFE = /[\\/:*?"<>|]/g; @@ -131,6 +133,11 @@ export async function runPlan(args: string[], opts?: PlanCliOpts): Promise config.planner.provider ); + const streamTimeoutSec = resolvePlannerStreamTimeoutSec(config.planner); + const spinner = createSpinner({ prefix: "Loading... " }); + if (process.stdout.isTTY) { + spinner.start(); + } try { const planBody = await runner.runPlan(goal, { cwd: projectRoot, @@ -138,6 +145,8 @@ export async function runPlan(args: string[], opts?: PlanCliOpts): Promise repoContext, projectContext, projectContextSource, + streamTimeoutMs: streamTimeoutSec === 0 ? 0 : streamTimeoutSec * 1000, + onFirstChunk: process.stdout.isTTY ? () => spinner.stop() : undefined, }); const bodyToWrite = stripFilenameSlugLine(planBody); let slug: string; @@ -196,5 +205,7 @@ export async function runPlan(args: string[], opts?: PlanCliOpts): Promise } catch (err) { console.error("Plan generation failed:", (err as Error).message); process.exit(1); + } finally { + spinner.stop(); } } diff --git a/packages/cli-js/src/config/load.ts b/packages/cli-js/src/config/load.ts index a19e26d..8a0ab2f 100644 --- a/packages/cli-js/src/config/load.ts +++ b/packages/cli-js/src/config/load.ts @@ -1,20 +1,16 @@ /** - * loadConfig: runtime only. Reads planforge.json; throws if missing (no template fallback). - * getDefaultConfig: used only by init and config suggest. Reads templates/config/default-*.json. + * loadConfig: runtime only. Reads planforge.json and merges with template (default-*.json) by installed providers. + * getDefaultConfig: reads templates/config/default-*.json for init, config suggest, and as merge base in loadConfig. */ import { existsSync, readFileSync } from "fs"; import fs from "fs-extra"; import { resolve } from "path"; import { getTemplatesRoot } from "../utils/paths.js"; +import { checkClaude } from "../providers/claude.js"; +import { checkCodex } from "../providers/codex.js"; import type { PlanForgeConfig } from "./types.js"; -/** Inline defaults used only when merging partial planforge.json (file exists). Not used when template is required. */ -const MERGE_DEFAULTS: PlanForgeConfig = { - planner: { provider: "claude", model: "claude-opus-4-6" }, - implementer: { provider: "codex", model: "gpt-5.4" }, -}; - /** * Default config when planforge.json is missing. Reads from templates/config/default-*.json. * Throws if the template file is missing or invalid. @@ -41,32 +37,7 @@ export function getDefaultConfig(hasClaude: boolean, hasCodex: boolean): PlanFor } /** - * Default Doctor AI config (cheap models for workflow tests). Reads from templates/doctor/default-*.json. - * Same file naming as config: default-both, default-claude-only, default-codex-only. - */ -export function getDefaultDoctorAiConfig(hasClaude: boolean, hasCodex: boolean): PlanForgeConfig { - const fileName = - hasClaude && hasCodex - ? "default-both.json" - : hasClaude - ? "default-claude-only.json" - : hasCodex - ? "default-codex-only.json" - : "default-claude-only.json"; - const filePath = resolve(getTemplatesRoot(), "doctor", fileName); - if (!existsSync(filePath)) { - throw new Error(`Missing doctor template: ${filePath}. Run from repo root or ensure templates exist.`); - } - try { - const data = JSON.parse(readFileSync(filePath, "utf-8")) as PlanForgeConfig; - return data; - } catch (e) { - throw new Error(`Missing or invalid template: ${filePath}. Run from repo root or ensure templates exist.`); - } -} - -/** - * Load planforge.json for runtime commands (plan, implement, doctor). No template fallback. + * Load planforge.json for runtime commands (plan, implement, doctor). Merges with template (default-*.json) by installed providers. * Throws if planforge.json is missing; caller should direct user to planforge init. */ export async function loadConfig(projectRoot: string): Promise { @@ -74,11 +45,12 @@ export async function loadConfig(projectRoot: string): Promise if (!(await fs.pathExists(configPath))) { throw new Error("planforge.json not found. Run planforge init."); } + const mergeBase = getDefaultConfig(checkClaude(), checkCodex()); const loaded = (await fs.readJson(configPath)) as Partial; const planner = (loaded.planner ?? {}) as Partial; const implementer = (loaded.implementer ?? {}) as Partial; return { - planner: { ...MERGE_DEFAULTS.planner, ...planner, provider: planner.provider ?? MERGE_DEFAULTS.planner.provider }, - implementer: { ...MERGE_DEFAULTS.implementer, ...implementer, provider: implementer.provider ?? MERGE_DEFAULTS.implementer.provider }, + planner: { ...mergeBase.planner, ...planner, provider: planner.provider ?? mergeBase.planner.provider }, + implementer: { ...mergeBase.implementer, ...implementer, provider: implementer.provider ?? mergeBase.implementer.provider }, }; } diff --git a/packages/cli-js/src/config/timeout.ts b/packages/cli-js/src/config/timeout.ts new file mode 100644 index 0000000..b0cec83 --- /dev/null +++ b/packages/cli-js/src/config/timeout.ts @@ -0,0 +1,37 @@ +/** + * Resolve stream timeout (seconds) from planner/implementer config. + * 0 means no timeout. When streamTimeoutSec is not set, use effort-based default (planner) or 300 (implementer). + */ + +import type { PlanForgeConfig } from "./types.js"; + +/** Default seconds by planner effort when streamTimeoutSec is not set. */ +const PLANNER_EFFORT_DEFAULT_SEC: Record = { + high: 360, + medium: 180, + low: 120, +}; + +const IMPLEMENTER_DEFAULT_SEC = 300; + +/** + * Resolve planner stream timeout in seconds. 0 = no timeout. + */ +export function resolvePlannerStreamTimeoutSec(planner: PlanForgeConfig["planner"]): number { + if (planner.streamTimeoutSec !== undefined && planner.streamTimeoutSec !== null) { + return Math.max(0, Number(planner.streamTimeoutSec)); + } + const effort = (planner.effort ?? "").toLowerCase(); + return PLANNER_EFFORT_DEFAULT_SEC[effort] ?? 120; +} + +/** + * Resolve implementer stream timeout in seconds. 0 = no timeout. + */ +export function resolveImplementerStreamTimeoutSec(implementer: PlanForgeConfig["implementer"]): number { + if (implementer.streamTimeoutSec !== undefined && implementer.streamTimeoutSec !== null) { + return Math.max(0, Number(implementer.streamTimeoutSec)); + } + const effort = (implementer.effort ?? "").toLowerCase(); + return PLANNER_EFFORT_DEFAULT_SEC[effort] ?? IMPLEMENTER_DEFAULT_SEC; +} diff --git a/packages/cli-js/src/config/types.ts b/packages/cli-js/src/config/types.ts index 04e182e..c26b4f8 100644 --- a/packages/cli-js/src/config/types.ts +++ b/packages/cli-js/src/config/types.ts @@ -3,6 +3,6 @@ */ export interface PlanForgeConfig { - planner: { provider: string; model: string; effort?: string; reasoning?: string; asciiSlug?: boolean }; - implementer: { provider: string; model: string; effort?: string; reasoning?: string }; + planner: { provider: string; model: string; effort?: string; reasoning?: string; asciiSlug?: boolean; streamTimeoutSec?: number }; + implementer: { provider: string; model: string; effort?: string; reasoning?: string; streamTimeoutSec?: number }; } diff --git a/packages/cli-js/src/index.ts b/packages/cli-js/src/index.ts index 42e20d4..57a46b0 100644 --- a/packages/cli-js/src/index.ts +++ b/packages/cli-js/src/index.ts @@ -17,7 +17,7 @@ const program = new Command(); program .name("planforge") .description("Bring your own AI to Cursor. Use Claude or Codex inside Cursor Free.") - .version("0.1.7"); + .version("0.1.8"); program .command("init") diff --git a/packages/cli-js/src/providers/claude.ts b/packages/cli-js/src/providers/claude.ts index 9333cb5..6572d6c 100644 --- a/packages/cli-js/src/providers/claude.ts +++ b/packages/cli-js/src/providers/claude.ts @@ -45,6 +45,8 @@ export interface CompleteOneTurnOpts { interface StreamOpts extends CompleteOneTurnOpts { writeStdout?: boolean; + /** Stream timeout in ms. 0 or undefined = no timeout. */ + streamTimeoutMs?: number; } const CLAUDE_ONE_TURN_TIMEOUT_MS = 120_000; @@ -162,7 +164,16 @@ export async function runPlan(goal: string, opts?: PlanOpts): Promise { const fullPrompt = body + "\n\n---\n\nUser goal: " + goal; try { - return await runClaudeStreaming(fullPrompt, cwd); + let onFirstChunkFired = false; + const onChunk = opts?.onFirstChunk + ? (chunk: string) => { + if (!onFirstChunkFired && chunk.length > 0) { + onFirstChunkFired = true; + opts!.onFirstChunk!(); + } + } + : undefined; + return await runClaudeStreaming(fullPrompt, cwd, { streamTimeoutMs: opts?.streamTimeoutMs }, onChunk); } catch (err) { const msg = (err as { stdout?: string; stderr?: string; message?: string }).stdout ?? (err as { stderr?: string }).stderr @@ -204,7 +215,7 @@ export async function runImplement(prompt: string, opts?: ImplementOpts): Promis const fullPrompt = body + "\n\n---\n\nUser request: " + prompt; try { - return await runClaudeStreaming(fullPrompt, cwd); + return await runClaudeStreaming(fullPrompt, cwd, { streamTimeoutMs: opts?.streamTimeoutMs }); } catch (err) { const msg = (err as { stdout?: string; stderr?: string; message?: string }).stdout ?? (err as { stderr?: string }).stderr @@ -221,7 +232,7 @@ export async function runImplement(prompt: string, opts?: ImplementOpts): Promis function runClaudeStreaming( fullPrompt: string, cwd: string, - opts?: Pick, + opts?: Pick, onChunk?: (chunk: string) => void ): Promise { const exe = resolveClaudeExe(); @@ -232,6 +243,8 @@ function runClaudeStreaming( ) ); } + const timeoutMs = opts?.streamTimeoutMs; + const useTimeout = timeoutMs !== undefined && timeoutMs !== 0; return new Promise((resolve, reject) => { const chunks: Buffer[] = []; const stderrChunks: Buffer[] = []; @@ -264,6 +277,15 @@ function runClaudeStreaming( process.stderr.write(chunk); }; + const scheduleTimeout = (child: ReturnType) => { + if (!useTimeout) return () => {}; + const t = setTimeout(() => { + child.kill(); + finishReject(`Claude streaming timed out after ${Math.floor(timeoutMs! / 1000)}s`); + }, timeoutMs!); + return () => clearTimeout(t); + }; + if (process.platform === "win32") { const tempPath = join(tmpdir(), "planforge-claude-" + randomBytes(8).toString("hex") + ".txt"); writeFileSync(tempPath, fullPrompt, "utf-8"); @@ -274,12 +296,9 @@ function runClaudeStreaming( cwd, stdio: ["ignore", "pipe", "pipe"], }); - const timeout = setTimeout(() => { - child.kill(); - finishReject("Claude streaming timed out after 120s"); - }, CLAUDE_ONE_TURN_TIMEOUT_MS); + const clearTimeoutRef = scheduleTimeout(child); child.on("close", (code) => { - clearTimeout(timeout); + clearTimeoutRef(); try { unlinkSync(tempPath); } catch { @@ -296,7 +315,7 @@ function runClaudeStreaming( child.stdout?.on("data", handleStdout); child.stderr?.on("data", handleStderr); child.on("error", (err) => { - clearTimeout(timeout); + clearTimeoutRef(); finishReject(err.message); }); return; @@ -313,14 +332,11 @@ function runClaudeStreaming( } child.stdin?.end(); }); - const timeout = setTimeout(() => { - child.kill(); - finishReject("Claude streaming timed out after 120s"); - }, CLAUDE_ONE_TURN_TIMEOUT_MS); + const clearTimeoutRef = scheduleTimeout(child); child.stdout?.on("data", handleStdout); child.stderr?.on("data", handleStderr); child.on("close", (code) => { - clearTimeout(timeout); + clearTimeoutRef(); if (settled) return; if (code !== 0) { const stderr = Buffer.concat(stderrChunks).toString("utf-8").trim(); @@ -330,7 +346,7 @@ function runClaudeStreaming( finishResolve(); }); child.on("error", (err) => { - clearTimeout(timeout); + clearTimeoutRef(); finishReject(err.message); }); }); diff --git a/packages/cli-js/src/providers/codex.ts b/packages/cli-js/src/providers/codex.ts index 9f6f5ba..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)) { @@ -172,6 +173,10 @@ function runCodexExecStreaming( const exe = resolveCodexExe(); if (!exe) return Promise.reject(new Error(CODEX_NOT_FOUND_MSG)); + const timeoutMs = streamOpts?.timeoutMs; + const useTimeout = timeoutMs === undefined ? true : timeoutMs !== 0; + const effectiveMs = timeoutMs === undefined ? CODEX_ONE_TURN_TIMEOUT_MS : timeoutMs === 0 ? 0 : timeoutMs; + return new Promise((resolve, reject) => { const chunks: Buffer[] = []; const stderrChunks: Buffer[] = []; @@ -179,6 +184,18 @@ function runCodexExecStreaming( const writeStdout = streamOpts?.writeStdout ?? true; let settled = false; + const scheduleTimeout = (child: ReturnType) => { + if (!useTimeout || effectiveMs === 0) return () => {}; + const t = setTimeout(() => { + child.kill(); + if (!settled) { + settled = true; + reject(new Error(`Codex streaming timed out after ${Math.floor(effectiveMs / 1000)}s`)); + } + }, effectiveMs); + return () => clearTimeout(t); + }; + const finish = (code: number | null) => { if (settled) return; const out = Buffer.concat(chunks).toString("utf-8").trim(); @@ -222,15 +239,9 @@ function runCodexExecStreaming( ...opts, stdio: ["ignore", "pipe", "pipe"], }); - const timeout = setTimeout(() => { - child.kill(); - if (!settled) { - settled = true; - reject(new Error(`Codex streaming timed out after ${Math.floor((streamOpts?.timeoutMs ?? CODEX_ONE_TURN_TIMEOUT_MS) / 1000)}s`)); - } - }, streamOpts?.timeoutMs ?? CODEX_ONE_TURN_TIMEOUT_MS); + const clearTimeoutRef = scheduleTimeout(child); child.on("close", (code) => { - clearTimeout(timeout); + clearTimeoutRef(); try { unlinkSync(tempPath); } catch { @@ -241,7 +252,7 @@ function runCodexExecStreaming( child.stdout?.on("data", handleStdout); child.stderr?.on("data", handleStderr); child.on("error", (err) => { - clearTimeout(timeout); + clearTimeoutRef(); if (!settled) { settled = true; reject(err); @@ -250,25 +261,31 @@ function runCodexExecStreaming( return; } - const child = spawn(exe, ["exec", fullPrompt], { + const child = spawn(exe, ["exec", "-"], { ...opts, - stdio: ["ignore", "pipe", "pipe"], + stdio: ["pipe", "pipe", "pipe"], }); - const timeout = setTimeout(() => { - child.kill(); + const clearTimeoutRef = scheduleTimeout(child); + child.stdin.on("error", (err: NodeJS.ErrnoException) => { + if (err.code === "EPIPE") { + return; + } + clearTimeoutRef(); if (!settled) { settled = true; - reject(new Error(`Codex streaming timed out after ${Math.floor((streamOpts?.timeoutMs ?? CODEX_ONE_TURN_TIMEOUT_MS) / 1000)}s`)); + reject(err); } - }, streamOpts?.timeoutMs ?? CODEX_ONE_TURN_TIMEOUT_MS); + }); + child.stdin.write(fullPrompt, "utf-8"); + child.stdin.end(); child.stdout?.on("data", handleStdout); child.stderr?.on("data", handleStderr); child.on("close", (code) => { - clearTimeout(timeout); + clearTimeoutRef(); finish(code); }); child.on("error", (err) => { - clearTimeout(timeout); + clearTimeoutRef(); if (!settled) { settled = true; reject(err); @@ -304,7 +321,19 @@ export async function runPlan(goal: string, opts?: PlanOpts): Promise { const fullPrompt = body + "\n\n---\n\nUser goal: " + goal; try { - return await runCodexExecStreaming(fullPrompt, cwd, true); + let onFirstChunkFired = false; + const streamOpts: { timeoutMs?: number; onChunk?: (chunk: string) => void } = { + timeoutMs: opts?.streamTimeoutMs, + }; + if (opts?.onFirstChunk) { + streamOpts.onChunk = (chunk: string) => { + if (!onFirstChunkFired && chunk.length > 0) { + onFirstChunkFired = true; + opts.onFirstChunk!(); + } + }; + } + return await runCodexExecStreaming(fullPrompt, cwd, true, streamOpts); } catch (err) { const msg = (err as { stdout?: string; stderr?: string; message?: string }).stdout ?? (err as { stderr?: string }).stderr @@ -346,7 +375,9 @@ export async function runImplement(prompt: string, opts?: ImplementOpts): Promis const fullPrompt = body + "\n\n---\n\nUser request: " + prompt; try { - return await runCodexExecStreaming(fullPrompt, cwd); + return await runCodexExecStreaming(fullPrompt, cwd, false, { + timeoutMs: opts?.streamTimeoutMs, + }); } catch (err) { const msg = (err as { stdout?: string; stderr?: string; message?: string }).stdout ?? (err as { stderr?: string }).stderr diff --git a/packages/cli-js/src/providers/registry.ts b/packages/cli-js/src/providers/registry.ts index 06b7a5e..db6bd62 100644 --- a/packages/cli-js/src/providers/registry.ts +++ b/packages/cli-js/src/providers/registry.ts @@ -15,6 +15,10 @@ export interface PlanOpts { projectContext?: string; /** Source file for projectContext, e.g. AGENTS.md or CLAUDE.md. */ projectContextSource?: string; + /** Stream timeout in ms. 0 = no timeout. */ + streamTimeoutMs?: number; + /** Called once when the first output chunk is received (e.g. to stop a loading spinner). */ + onFirstChunk?: () => void; } export interface ImplementOpts { @@ -33,6 +37,8 @@ export interface ImplementOpts { projectContextSource?: string; /** Recent commit (oneline) per file for files to focus on. Capped in size. */ recentCommitsPerFile?: string; + /** Stream timeout in ms. 0 = no timeout. */ + streamTimeoutMs?: number; } export interface PlannerRunner { diff --git a/packages/cli-js/src/utils/spinner.ts b/packages/cli-js/src/utils/spinner.ts new file mode 100644 index 0000000..d42663c --- /dev/null +++ b/packages/cli-js/src/utils/spinner.ts @@ -0,0 +1,65 @@ +/** + * Inline spinner for TTY: rotating frame with optional prefix. + * Used by plan (loading) and doctor (response wait). Call start() before async work, + * stop() or clear() when done (stop clears the line; clear() only clears for custom content). + */ + +import readline from "readline"; + +const SPINNER_FRAMES = ["|", "/", "-", "\\"]; +const DIM = "\x1b[2m"; +const RESET = "\x1b[0m"; + +export interface SpinnerOptions { + /** Text before the spinning character (dimmed). Default "Loading... ". */ + prefix?: string; + /** Frame interval in ms. Default 80. */ + intervalMs?: number; + /** Output stream. Default process.stdout. */ + stream?: NodeJS.WritableStream; + /** Only start when stream is TTY. Default true. */ + onlyWhenTty?: boolean; +} + +export interface Spinner { + start(): void; + stop(): void; + /** Clear the current line (e.g. before writing final content on the same line). */ + clear(): void; +} + +export function createSpinner(options?: SpinnerOptions): Spinner { + const stream = (options?.stream ?? process.stdout) as NodeJS.WritableStream & { isTTY?: boolean }; + const prefix = options?.prefix ?? "Loading... "; + const intervalMs = options?.intervalMs ?? 80; + const onlyWhenTty = options?.onlyWhenTty ?? true; + + let intervalId: ReturnType | null = null; + + const clear = () => { + if (stream === process.stdout && process.stdout.isTTY) { + readline.clearLine(process.stdout, 0); + readline.cursorTo(process.stdout, 0); + } + }; + + return { + start() { + if (onlyWhenTty && !stream.isTTY) return; + let idx = 0; + intervalId = setInterval(() => { + clear(); + stream.write(`${DIM}${prefix}${RESET}${SPINNER_FRAMES[idx % SPINNER_FRAMES.length]}`); + idx++; + }, intervalMs); + }, + stop() { + if (intervalId !== null) { + clearInterval(intervalId); + intervalId = null; + } + clear(); + }, + clear, + }; +} diff --git a/packages/cli-py/planforge/__init__.py b/packages/cli-py/planforge/__init__.py index 4b5f975..eb01215 100644 --- a/packages/cli-py/planforge/__init__.py +++ b/packages/cli-py/planforge/__init__.py @@ -1,3 +1,3 @@ """PlanForge CLI - Bring your own AI to Cursor.""" -__version__ = "0.1.7" +__version__ = "0.1.8" diff --git a/packages/cli-py/planforge/cli.py b/packages/cli-py/planforge/cli.py index 4731a2b..be980c1 100644 --- a/packages/cli-py/planforge/cli.py +++ b/packages/cli-py/planforge/cli.py @@ -11,7 +11,7 @@ @click.group() -@click.version_option(version="0.1.7") +@click.version_option(version="0.1.8") def main() -> None: """Bring your own AI to Cursor. Use Claude or Codex inside Cursor Free.""" diff --git a/packages/cli-py/planforge/commands/doctor.py b/packages/cli-py/planforge/commands/doctor.py index 84250be..ed91bfd 100644 --- a/packages/cli-py/planforge/commands/doctor.py +++ b/packages/cli-py/planforge/commands/doctor.py @@ -14,7 +14,7 @@ get_context_dir, get_templates_root, ) -from planforge.utils.config import load_config, get_default_doctor_ai_config +from planforge.utils.config import load_config from planforge.providers.claude import ( check_claude, complete_one_turn as claude_complete_one_turn, @@ -26,7 +26,7 @@ stream_one_turn as codex_stream_one_turn, ) from planforge.utils.tui import print_current_ai_config, select_from_list -from planforge.commands.model import _load_models_catalog +from planforge.commands.model import _load_models_catalog, _run_model_tui DOCTOR_MODE_STATIC = "static" URL_TEST_URL = "https://httpbin.org/get" @@ -129,17 +129,10 @@ def _handle_chunk(chunk: str) -> None: def run_doctor_mode_select() -> None: - """When doctor is run without subcommand: TTY shows Doctor AI config (default) then mode selection (static/ai/Quit); non-TTY runs static.""" + """When doctor is run without subcommand: TTY shows mode selection (static/ai/Quit) first; non-TTY runs static.""" if not sys.stdin.isatty(): run_doctor([]) return - has_claude = check_claude() - has_codex = check_codex() - try: - doctor_ai_config = get_default_doctor_ai_config(has_claude, has_codex) - print_current_ai_config(doctor_ai_config, "Doctor AI config (default)") - except (FileNotFoundError, RuntimeError): - pass mode_items = [ ("static – Check environment and providers", DOCTOR_MODE_STATIC), ("ai – Run workflow tests with AI", DOCTOR_MODE_AI), @@ -309,95 +302,13 @@ def _load_workflow_mdc(project_root: str) -> str: ) -def _build_model_list_from_config(config: dict, has_claude: bool, has_codex: bool) -> list[tuple[str, str, bool]]: - seen: set[str] = set() - options: list[tuple[str, str, bool]] = [] - planner = config.get("planner") or {} - implementer = config.get("implementer") or {} - rec_key = f"{planner.get('provider', '')}|{planner.get('model', '')}" - for role in (planner, implementer): - prov = role.get("provider", "") - model = role.get("model", "") - key = f"{prov}|{model}" - if key in seen: - continue - seen.add(key) - if prov == "claude" and not has_claude: - continue - if prov == "codex" and not has_codex: - continue - options.append((prov, model, key == rec_key)) - return options - - -def _build_options_from_catalog( - catalog: dict, has_claude: bool, has_codex: bool -) -> list[tuple[str, str, bool]]: - """Build flat (provider, model, recommended) from models.json catalog for available providers.""" - options: list[tuple[str, str, bool]] = [] - seen: set[str] = set() - for provider_id, prov_data in catalog.get("providers", {}).items(): - if provider_id == "claude" and not has_claude: - continue - if provider_id == "codex" and not has_codex: - continue - models = prov_data.get("models", []) if isinstance(prov_data, dict) else [] - for m in models: - model_id = m.get("id", "") if isinstance(m, dict) else "" - key = f"{provider_id}|{model_id}" - if key in seen: - continue - seen.add(key) - options.append((provider_id, model_id, False)) - return options - - -def _select_provider_and_model( - catalog: dict, - has_claude: bool, - has_codex: bool, - role_label: str, -) -> tuple[str, str] | None: - """Interactive: select provider then model (last model = recommended). Returns (provider, model) or None if Quit.""" - providers_data = catalog.get("providers", {}) - provider_ids = [ - p for p in providers_data - if (p == "claude" and has_claude) or (p == "codex" and has_codex) - ] - if not provider_ids: - return None - - while True: - provider_items = [ - ( - f"{providers_data.get(p, {}).get('name', p) if isinstance(providers_data.get(p), dict) else p} ({p})", - p, - ) - for p in provider_ids - ] - provider_id = select_from_list( - provider_items, - f"Select {role_label} [Up/Down] Enter to confirm", - ) - if provider_id is None: - return None - prov = providers_data.get(provider_id) or {} - models = prov.get("models", []) if isinstance(prov, dict) else [] - if not models: - continue - model_items = [] - for i, m in enumerate(models): - mid = m.get("id", "") if isinstance(m, dict) else "" - label = m.get("label", mid) if isinstance(m, dict) else mid - rec = " (recommended)" if i == len(models) - 1 else "" - model_items.append((f"{label} ({mid}){rec}", mid)) - model_id = select_from_list( - model_items, - "[Up/Down] model Enter to confirm (last = recommended)", - ) - if model_id is None: - continue - return (provider_id, model_id) +def _is_valid_provider_model(catalog: dict, provider: str, model: str) -> bool: + """Validate that provider+model exists in catalog (for --provider --model).""" + prov = catalog.get("providers", {}).get(provider) + if not isinstance(prov, dict): + return False + models = prov.get("models", []) + return any(isinstance(m, dict) and m.get("id") == model for m in models) def run_doctor_ai(args: list[str]) -> None: @@ -410,24 +321,12 @@ def run_doctor_ai(args: list[str]) -> None: print("Failed to load planforge.json:", e, file=sys.stderr) raise SystemExit(1) from e - catalog = None try: catalog = _load_models_catalog() - except FileNotFoundError: - catalog = None - - if catalog is not None: - options = _build_options_from_catalog(catalog, has_claude, has_codex) - else: - options = _build_model_list_from_config(config, has_claude, has_codex) - - if not options: - print("No AI provider available. Install Claude or Codex CLI and run planforge init.", file=sys.stderr) - raise SystemExit(1) - - # recommended = current planforge.json planner (plan assumption) - planner_key = f"{config.planner.get('provider', '')}|{config.planner.get('model', '')}" - options = [(p, m, (p + "|" + m) == planner_key) for (p, m, _) in options] + except FileNotFoundError as e: + print(e, file=sys.stderr) + print("doctor ai uses the same model catalog as planforge model. Run pnpm run build in cli-js or use planforge model.", file=sys.stderr) + raise SystemExit(1) from e provider_arg = None model_arg = None @@ -454,180 +353,171 @@ 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 exit_code = 0 - doctor_ai_config = None - if is_interactive: - try: - doctor_ai_config = get_default_doctor_ai_config(has_claude, has_codex) - except (FileNotFoundError, RuntimeError): - pass - while True: - if provider_arg and model_arg: - match = next((o for o in options if o[0] == provider_arg and o[1] == model_arg), None) - if not match: - print(f"No matching option for --provider {provider_arg} --model {model_arg}", file=sys.stderr) - raise SystemExit(1) - selected_planner = selected_implementer = (match[0], match[1]) - elif use_planner_implementer_selection: - pl = (doctor_ai_config or config)["planner"] - impl = (doctor_ai_config or config)["implementer"] - pl_extra = f"effort: {pl['effort']}" if pl.get("effort") else (f"reasoning: {pl['reasoning']}" if pl.get("reasoning") else None) - impl_extra = f"effort: {impl['effort']}" if impl.get("effort") else (f"reasoning: {impl['reasoning']}" if impl.get("reasoning") else None) - print("\n Doctor AI config") + selected_planner = None + selected_implementer = None + + if provider_arg and model_arg: + if not _is_valid_provider_model(catalog, provider_arg, model_arg): + print(f"No matching option for --provider {provider_arg} --model {model_arg}. Check models.json catalog.", file=sys.stderr) + raise SystemExit(1) + selected_planner = selected_implementer = (provider_arg, model_arg) + elif is_interactive: + selected_planner = (config["planner"].get("provider", ""), config["planner"].get("model", "")) + selected_implementer = (config["implementer"].get("provider", ""), config["implementer"].get("model", "")) + while True: + pl = config["planner"] + impl = config["implementer"] + pl_extra = f"effort: {pl.get('effort')}" if pl.get("effort") else (f"reasoning: {pl.get('reasoning')}" if pl.get("reasoning") else None) + impl_extra = f"effort: {impl.get('effort')}" if impl.get("effort") else (f"reasoning: {impl.get('reasoning')}" if impl.get("reasoning") else None) + print("\n Doctor AI config (planforge.json)") print(" ----------------") print(f" {'planner'.ljust(12)}: {pl.get('provider', '').ljust(6)} / {pl.get('model', '').ljust(20)}{' (' + pl_extra + ')' if pl_extra else ''}") print(f" {'implementer'.ljust(12)}: {impl.get('provider', '').ljust(6)} / {impl.get('model', '').ljust(20)}{' (' + impl_extra + ')' if impl_extra else ''}") + action = select_from_list( + [("Run immediately", "run"), ("Change models", "change")], + "Action [Up/Down] Enter to confirm", + ) + if action is None: + raise SystemExit(exit_code) + if action == "run": + break + first_role = select_from_list( [("planner", "planner"), ("implementer", "implementer")], - "Mode [Up/Down] Enter to confirm", + "Role to change [Up/Down] Enter to confirm", ) if first_role is None: raise SystemExit(exit_code) second_role = "implementer" if first_role == "planner" else "planner" - first_sel = _select_provider_and_model(catalog, has_claude, has_codex, first_role) - if first_sel is None: - raise SystemExit(exit_code) - second_sel = _select_provider_and_model(catalog, has_claude, has_codex, second_role) - if second_sel is None: + first_result = _run_model_tui( + catalog, project_root, has_claude, has_codex, config, + preselected_mode=first_role, + ) + if first_result is None: raise SystemExit(exit_code) - if first_role == "planner": - selected_planner = first_sel - selected_implementer = second_sel - else: - selected_planner = second_sel - selected_implementer = first_sel - elif sys.stdin.isatty(): - # Fallback: flat list when catalog missing - pl = (doctor_ai_config or config)["planner"] - impl = (doctor_ai_config or config)["implementer"] - pl_extra = f"effort: {pl['effort']}" if pl.get("effort") else (f"reasoning: {pl['reasoning']}" if pl.get("reasoning") else None) - impl_extra = f"effort: {impl['effort']}" if impl.get("effort") else (f"reasoning: {impl['reasoning']}" if impl.get("reasoning") else None) - print("\n Doctor AI config") - print(" ----------------") - print(f" {'planner'.ljust(12)}: {pl.get('provider', '').ljust(6)} / {pl.get('model', '').ljust(20)}{' (' + pl_extra + ')' if pl_extra else ''}") - print(f" {'implementer'.ljust(12)}: {impl.get('provider', '').ljust(6)} / {impl.get('model', '').ljust(20)}{' (' + impl_extra + ')' if impl_extra else ''}") - flat_items = [(f"{prov} ({model})", (prov, model)) for (prov, model, _) in options] - selected = select_from_list( - flat_items, - "Select AI for workflow test [Up/Down] Enter to confirm", + second_result = _run_model_tui( + catalog, project_root, has_claude, has_codex, config, + preselected_mode=second_role, ) - if selected is None: + if second_result is None: raise SystemExit(exit_code) - selected_planner = selected_implementer = selected - else: - selected_planner = selected_implementer = (options[0][0], options[0][1]) - - planner_complete = claude_complete_one_turn if selected_planner[0] == "claude" else codex_complete_one_turn - implementer_complete = claude_complete_one_turn if selected_implementer[0] == "claude" else codex_complete_one_turn - - _cyan = "\033[36m" - _dim = "\033[2m" - _green = "\033[92m" - _red = "\033[31m" - _reset = "\033[0m" - _check = "\u2713" - _cross = "\u2717" + + planner_result = first_result if first_role == "planner" else second_result + implementer_result = second_result if first_role == "planner" else first_result + selected_planner = (planner_result[1]["provider"], planner_result[1]["model"]) + selected_implementer = (implementer_result[1]["provider"], implementer_result[1]["model"]) + config["planner"] = {**config["planner"], **planner_result[1]} + config["implementer"] = {**config["implementer"], **implementer_result[1]} + else: + selected_planner = (config["planner"].get("provider", ""), config["planner"].get("model", "")) + selected_implementer = (config["implementer"].get("provider", ""), config["implementer"].get("model", "")) + + planner_complete = claude_complete_one_turn if selected_planner[0] == "claude" else codex_complete_one_turn + implementer_complete = claude_complete_one_turn if selected_implementer[0] == "claude" else codex_complete_one_turn + + _cyan = "\033[36m" + _dim = "\033[2m" + _green = "\033[92m" + _red = "\033[31m" + _reset = "\033[0m" + _check = "\u2713" + _cross = "\u2717" + print("") + print(_cyan + " \u2500\u2500\u2500 Workflow tests \u2500\u2500\u2500" + _reset) + print(_dim + " planner: " + selected_planner[0] + " / " + selected_planner[1] + " \u00b7 implementer: " + selected_implementer[0] + " / " + selected_implementer[1] + _reset) + print("") + + tc1_pass = False + tc2_pass = False + tc3_pass = False + tc4_pass = False + if sys.stdout.isatty(): + tc1_pass, tc1_error = _run_streaming_doctor_tc( + "TC1 (plan request)", + system_prompt, + tc1_msg, + ["planforge plan", "run_plan.sh", "run_plan.ps1"], + selected_planner[0], + selected_planner[1], + project_root, + ) + if tc1_error: + print("TC1 (plan request) error:", tc1_error, file=sys.stderr) + + tc2_pass, tc2_error = _run_streaming_doctor_tc( + "TC2 (implement request)", + system_prompt, + tc2_msg, + ["planforge implement", "run_implement.sh", "run_implement.ps1"], + selected_implementer[0], + selected_implementer[1], + project_root, + ) + if tc2_error: + print("TC2 (implement request) error:", tc2_error, file=sys.stderr) + + tc3_pass, tc3_error = _run_streaming_doctor_tc( + "TC3 (/p with implementation-style request)", + system_prompt, + tc3_msg, + ["planforge plan", "run_plan.sh", "run_plan.ps1"], + selected_planner[0], + selected_planner[1], + project_root, + ) + if tc3_error: + print("TC3 (/p with implementation-style request) error:", tc3_error, file=sys.stderr) + + print(f" {_cyan}TC4 (URL fetch){_reset}") + tc4_pass, tc4_error = _run_url_fetch_tc() + if tc4_error: + print("TC4 (URL fetch) error:", tc4_error, file=sys.stderr) + print(" " + (_green + _check + " PASS " + _reset if tc4_pass else _red + _cross + " FAIL " + _reset) + "GET " + URL_TEST_URL) + + print(_cyan + " \u2500\u2500\u2500 Results \u2500\u2500\u2500" + _reset) + print(" " + (_green + _check + " PASS" + _reset if tc1_pass else _red + _cross + " FAIL" + _reset) + " TC1 (plan request)") + print(" " + (_green + _check + " PASS" + _reset if tc2_pass else _red + _cross + " FAIL" + _reset) + " TC2 (implement request)") + print(" " + (_green + _check + " PASS" + _reset if tc3_pass else _red + _cross + " FAIL" + _reset) + " TC3 (/p with implementation-style request)") + print(" " + (_green + _check + " PASS" + _reset if tc4_pass else _red + _cross + " FAIL" + _reset) + " TC4 (URL fetch)") print("") - print(_cyan + " \u2500\u2500\u2500 Workflow tests \u2500\u2500\u2500" + _reset) - print(_dim + " planner: " + selected_planner[0] + " / " + selected_planner[1] + " \u00b7 implementer: " + selected_implementer[0] + " / " + selected_implementer[1] + _reset) + else: + try: + tc1_response = planner_complete(system_prompt, tc1_msg, cwd=project_root, model=selected_planner[1]) + tc1_pass = "planforge plan" in tc1_response or "run_plan.sh" in tc1_response or "run_plan.ps1" in tc1_response + except Exception as e: + print("TC1 (plan request) error:", e, file=sys.stderr) + try: + tc2_response = implementer_complete(system_prompt, tc2_msg, cwd=project_root, model=selected_implementer[1]) + tc2_pass = "planforge implement" in tc2_response or "run_implement.sh" in tc2_response or "run_implement.ps1" in tc2_response + except Exception as e: + print("TC2 (implement request) error:", e, file=sys.stderr) + try: + tc3_response = planner_complete(system_prompt, tc3_msg, cwd=project_root, model=selected_planner[1]) + tc3_pass = "planforge plan" in tc3_response or "run_plan.sh" in tc3_response or "run_plan.ps1" in tc3_response + except Exception as e: + print("TC3 (/p with implementation-style request) error:", e, file=sys.stderr) + tc4_pass, tc4_err = _run_url_fetch_tc() + if tc4_err: + print("TC4 (URL fetch) error:", tc4_err, file=sys.stderr) + + _cyan2 = "\033[36m" + _green2 = "\033[92m" + _red2 = "\033[31m" + _reset2 = "\033[0m" + _check2 = "\u2713" + _cross2 = "\u2717" + print(_cyan2 + " \u2500\u2500\u2500 Results \u2500\u2500\u2500" + _reset2) + print(" " + (_green2 + _check2 + " PASS" + _reset2 if tc1_pass else _red2 + _cross2 + " FAIL" + _reset2) + " TC1 (plan request)") + print(" " + (_green2 + _check2 + " PASS" + _reset2 if tc2_pass else _red2 + _cross2 + " FAIL" + _reset2) + " TC2 (implement request)") + print(" " + (_green2 + _check2 + " PASS" + _reset2 if tc3_pass else _red2 + _cross2 + " FAIL" + _reset2) + " TC3 (/p with implementation-style request)") + print(" " + (_green2 + _check2 + " PASS" + _reset2 if tc4_pass else _red2 + _cross2 + " FAIL" + _reset2) + " TC4 (URL fetch)") print("") - - tc1_pass = False - tc2_pass = False - tc3_pass = False - tc4_pass = False - if sys.stdout.isatty(): - tc1_pass, tc1_error = _run_streaming_doctor_tc( - "TC1 (plan request)", - system_prompt, - tc1_msg, - ["planforge plan", "run_plan.sh", "run_plan.ps1"], - selected_planner[0], - selected_planner[1], - project_root, - ) - if tc1_error: - print("TC1 (plan request) error:", tc1_error, file=sys.stderr) - - tc2_pass, tc2_error = _run_streaming_doctor_tc( - "TC2 (implement request)", - system_prompt, - tc2_msg, - ["planforge implement", "run_implement.sh", "run_implement.ps1"], - selected_implementer[0], - selected_implementer[1], - project_root, - ) - if tc2_error: - print("TC2 (implement request) error:", tc2_error, file=sys.stderr) - - tc3_pass, tc3_error = _run_streaming_doctor_tc( - "TC3 (/p with implementation-style request)", - system_prompt, - tc3_msg, - ["planforge plan", "run_plan.sh", "run_plan.ps1"], - selected_planner[0], - selected_planner[1], - project_root, - ) - if tc3_error: - print("TC3 (/p with implementation-style request) error:", tc3_error, file=sys.stderr) - - print(f" {_cyan}TC4 (URL fetch){_reset}") - tc4_pass, tc4_error = _run_url_fetch_tc() - if tc4_error: - print("TC4 (URL fetch) error:", tc4_error, file=sys.stderr) - print(" " + (_green + _check + " PASS " + _reset if tc4_pass else _red + _cross + " FAIL " + _reset) + "GET " + URL_TEST_URL) - - print(_cyan + " \u2500\u2500\u2500 Results \u2500\u2500\u2500" + _reset) - print(" " + (_green + _check + " PASS" + _reset if tc1_pass else _red + _cross + " FAIL" + _reset) + " TC1 (plan request)") - print(" " + (_green + _check + " PASS" + _reset if tc2_pass else _red + _cross + " FAIL" + _reset) + " TC2 (implement request)") - print(" " + (_green + _check + " PASS" + _reset if tc3_pass else _red + _cross + " FAIL" + _reset) + " TC3 (/p with implementation-style request)") - print(" " + (_green + _check + " PASS" + _reset if tc4_pass else _red + _cross + " FAIL" + _reset) + " TC4 (URL fetch)") - print("") - else: - try: - tc1_response = planner_complete(system_prompt, tc1_msg, cwd=project_root, model=selected_planner[1]) - tc1_pass = "planforge plan" in tc1_response or "run_plan.sh" in tc1_response or "run_plan.ps1" in tc1_response - except Exception as e: - print("TC1 (plan request) error:", e, file=sys.stderr) - try: - tc2_response = implementer_complete(system_prompt, tc2_msg, cwd=project_root, model=selected_implementer[1]) - tc2_pass = "planforge implement" in tc2_response or "run_implement.sh" in tc2_response or "run_implement.ps1" in tc2_response - except Exception as e: - print("TC2 (implement request) error:", e, file=sys.stderr) - try: - tc3_response = planner_complete(system_prompt, tc3_msg, cwd=project_root, model=selected_planner[1]) - tc3_pass = "planforge plan" in tc3_response or "run_plan.sh" in tc3_response or "run_plan.ps1" in tc3_response - except Exception as e: - print("TC3 (/p with implementation-style request) error:", e, file=sys.stderr) - tc4_pass, tc4_err = _run_url_fetch_tc() - if tc4_err: - print("TC4 (URL fetch) error:", tc4_err, file=sys.stderr) - - _cyan2 = "\033[36m" - _green2 = "\033[92m" - _red2 = "\033[31m" - _reset2 = "\033[0m" - _check2 = "\u2713" - _cross2 = "\u2717" - print(_cyan2 + " \u2500\u2500\u2500 Results \u2500\u2500\u2500" + _reset2) - print(" " + (_green2 + _check2 + " PASS" + _reset2 if tc1_pass else _red2 + _cross2 + " FAIL" + _reset2) + " TC1 (plan request)") - print(" " + (_green2 + _check2 + " PASS" + _reset2 if tc2_pass else _red2 + _cross2 + " FAIL" + _reset2) + " TC2 (implement request)") - print(" " + (_green2 + _check2 + " PASS" + _reset2 if tc3_pass else _red2 + _cross2 + " FAIL" + _reset2) + " TC3 (/p with implementation-style request)") - print(" " + (_green2 + _check2 + " PASS" + _reset2 if tc4_pass else _red2 + _cross2 + " FAIL" + _reset2) + " TC4 (URL fetch)") - print("") - if not tc1_pass or not tc2_pass or not tc3_pass or not tc4_pass: - exit_code = 1 - if not is_interactive: - raise SystemExit(exit_code) + if not tc1_pass or not tc2_pass or not tc3_pass or not tc4_pass: + exit_code = 1 + raise SystemExit(exit_code) diff --git a/packages/cli-py/planforge/commands/implement.py b/packages/cli-py/planforge/commands/implement.py index 0e52103..26db77e 100644 --- a/packages/cli-py/planforge/commands/implement.py +++ b/packages/cli-py/planforge/commands/implement.py @@ -6,7 +6,7 @@ from pathlib import Path from planforge.utils.paths import get_project_root -from planforge.utils.config import load_config +from planforge.utils.config import load_config, resolve_implementer_stream_timeout_sec from planforge.utils.context import load_merged_context from planforge.utils.active_plan import get_active_plan_path from planforge.utils.plan_files import parse_files_from_plan @@ -157,6 +157,7 @@ def run_implement(args: list[str], opts: dict | None = None) -> None: recent_commits_per_file = ( _build_recent_commits_for_files(project_root, files_to_change) if files_to_change else None ) + stream_timeout_sec = resolve_implementer_stream_timeout_sec(config["implementer"]) run_opts = { "cwd": project_root, "context": context, @@ -166,6 +167,7 @@ def run_implement(args: list[str], opts: dict | None = None) -> None: "projectContext": project_context, "projectContextSource": project_context_source, "recentCommitsPerFile": recent_commits_per_file, + "streamTimeoutSec": stream_timeout_sec, } try: result = run(prompt, run_opts) diff --git a/packages/cli-py/planforge/commands/model.py b/packages/cli-py/planforge/commands/model.py index 6253145..901a919 100644 --- a/packages/cli-py/planforge/commands/model.py +++ b/packages/cli-py/planforge/commands/model.py @@ -25,15 +25,22 @@ def _run_model_tui( has_claude: bool, has_codex: bool, default_config: dict | None = None, + *, + preselected_mode: str | None = None, ) -> tuple[str, dict] | None: - """Interactive TUI: mode => provider => model + effort/reasoning. Returns (mode, role_config) or None if quit.""" + """Interactive TUI: mode => provider => model + effort/reasoning. Returns (mode, role_config) or None if quit. + When preselected_mode is set, skip mode selection (e.g. doctor ai uses planner/implementer). + """ modes = catalog.get("modes", ["planner", "implementer"]) mode_providers = catalog.get("modeProviders", {}) providers_data = catalog.get("providers", {}) - mode = select_from_list([(m, m) for m in modes], "Mode: [Up/Down] Enter to confirm") - if mode is None: - return None + if preselected_mode is not None and preselected_mode in modes: + mode = preselected_mode + else: + mode = select_from_list([(m, m) for m in modes], "Mode: [Up/Down] Enter to confirm") + if mode is None: + return None provider_ids = mode_providers.get(mode, list(providers_data.keys())) available = [p for p in provider_ids if (p == "claude" and has_claude) or (p == "codex" and has_codex)] diff --git a/packages/cli-py/planforge/commands/plan.py b/packages/cli-py/planforge/commands/plan.py index 1063484..052a5ea 100644 --- a/packages/cli-py/planforge/commands/plan.py +++ b/packages/cli-py/planforge/commands/plan.py @@ -2,11 +2,12 @@ import json import re +import sys from datetime import datetime from pathlib import Path from planforge.utils.paths import get_project_root, get_plans_dir, get_dated_plans_dir, get_date_parts -from planforge.utils.config import load_config +from planforge.utils.config import load_config, resolve_planner_stream_timeout_sec from planforge.utils.context import load_merged_context from planforge.utils.url_fetch import fetch_urls_context from planforge.utils.repo_context import get_repo_context @@ -119,13 +120,17 @@ def run_plan(args: list[str], opts: dict | None = None) -> None: raise SystemExit(1) repo_context = get_repo_context(project_root, goal) project_context, project_context_source = get_project_context(project_root, provider) + stream_timeout_sec = resolve_planner_stream_timeout_sec(config["planner"]) run_opts = { "cwd": project_root, "context": context, "repoContext": repo_context, "projectContext": project_context, "projectContextSource": project_context_source, + "streamTimeoutSec": stream_timeout_sec, } + if sys.stdout.isatty(): + print("Loading...", flush=True) try: plan_body = run(goal, run_opts) except Exception as e: diff --git a/packages/cli-py/planforge/providers/claude.py b/packages/cli-py/planforge/providers/claude.py index 5456ad1..282973f 100644 --- a/packages/cli-py/planforge/providers/claude.py +++ b/packages/cli-py/planforge/providers/claude.py @@ -316,8 +316,10 @@ def run_plan(goal: str, opts: dict | None = None) -> str: body += "\n\n---\n\nConversation context:\n" + (opts["context"] or "").strip() body += "\n\n---\n\n" + load_prompt(prompts_dir / "append-i18n.md") + "\n\n" + load_prompt(prompts_dir / "append-slug.md") full_prompt = body + "\n\n---\n\nUser goal: " + goal + timeout_sec = opts.get("streamTimeoutSec", CLAUDE_ONE_TURN_TIMEOUT_S) + timeout_arg = None if timeout_sec == 0 else (timeout_sec or CLAUDE_ONE_TURN_TIMEOUT_S) try: - return _run_claude_streaming(full_prompt, cwd) + return _run_claude_streaming(full_prompt, cwd, timeout=timeout_arg) except Exception as e: raise RuntimeError("Claude plan failed: " + str(e)) from e @@ -343,7 +345,9 @@ def run_implement(prompt: str, opts: dict | None = None) -> str: if (opts.get("codeContext") or "").strip(): body += "\n\n---\n\nRelevant file contents:\n" + (opts["codeContext"] or "").strip() full_prompt = body + "\n\n---\n\nUser request: " + prompt + timeout_sec = opts.get("streamTimeoutSec", CLAUDE_ONE_TURN_TIMEOUT_S) + timeout_arg = None if timeout_sec == 0 else (timeout_sec or CLAUDE_ONE_TURN_TIMEOUT_S) try: - return _run_claude_streaming(full_prompt, cwd) + return _run_claude_streaming(full_prompt, cwd, timeout=timeout_arg) except Exception as e: raise RuntimeError("Claude implement failed: " + str(e)) from e diff --git a/packages/cli-py/planforge/providers/codex.py b/packages/cli-py/planforge/providers/codex.py index 4325e9e..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,)) @@ -249,8 +256,10 @@ def run_plan(goal: str, opts: dict | None = None) -> str: body += "\n\n---\n\nConversation context:\n" + (opts["context"] or "").strip() body += "\n\n---\n\n" + load_prompt(prompts_dir / "append-i18n.md") + "\n\n" + load_prompt(prompts_dir / "append-slug.md") full_prompt = body + "\n\n---\n\nUser goal: " + goal + timeout_sec = opts.get("streamTimeoutSec") + timeout_arg = None if timeout_sec == 0 else (timeout_sec if timeout_sec is not None else CODEX_ONE_TURN_TIMEOUT_S) try: - return _run_codex_exec_streaming(full_prompt, cwd, allow_plan_fallback=True) + return _run_codex_exec_streaming(full_prompt, cwd, allow_plan_fallback=True, timeout=timeout_arg) except Exception as e: raise RuntimeError("Codex plan failed: " + str(e)) from e @@ -276,7 +285,9 @@ def run_implement(prompt: str, opts: dict | None = None) -> str: if (opts.get("codeContext") or "").strip(): body += "\n\n---\n\nRelevant file contents:\n" + (opts["codeContext"] or "").strip() full_prompt = body + "\n\n---\n\nUser request: " + prompt + timeout_sec = opts.get("streamTimeoutSec") + timeout_arg = None if timeout_sec == 0 else (timeout_sec if timeout_sec is not None else CODEX_ONE_TURN_TIMEOUT_S) try: - return _run_codex_exec(full_prompt, cwd) + return _run_codex_exec_streaming(full_prompt, cwd, allow_plan_fallback=False, timeout=timeout_arg) except Exception as e: raise RuntimeError("Codex implement failed: " + str(e)) from e diff --git a/packages/cli-py/planforge/utils/config.py b/packages/cli-py/planforge/utils/config.py index b76de96..50cb11b 100644 --- a/packages/cli-py/planforge/utils/config.py +++ b/packages/cli-py/planforge/utils/config.py @@ -1,16 +1,14 @@ -"""load_config: runtime only, reads planforge.json; raises if missing. get_default_config: init/config suggest only, reads templates.""" +"""load_config: runtime only, reads planforge.json and merges with template (default-*.json) by installed providers. +get_default_config: reads templates for init, config suggest, and as merge base in load_config.""" import json from pathlib import Path from planforge.utils.paths import get_project_root, get_templates_root - -# Inline defaults used only when merging partial planforge.json (file exists). -_MERGE_DEFAULTS = { - "planner": {"provider": "claude", "model": "claude-opus-4-6"}, - "implementer": {"provider": "codex", "model": "gpt-5.4"}, -} +# Default seconds by effort when streamTimeoutSec is not set (planner and implementer). +_PLANNER_EFFORT_DEFAULT_SEC = {"high": 360, "medium": 180, "low": 120} +_IMPLEMENTER_DEFAULT_SEC = 300 def get_default_config(has_claude: bool, has_codex: bool) -> dict: @@ -61,8 +59,28 @@ def get_default_doctor_ai_config(has_claude: bool, has_codex: bool) -> dict: ) from e + +def resolve_planner_stream_timeout_sec(planner: dict) -> int: + """Resolve planner stream timeout in seconds. 0 = no timeout.""" + if planner.get("streamTimeoutSec") is not None: + return max(0, int(planner["streamTimeoutSec"])) + effort = (planner.get("effort") or "").lower() + return _PLANNER_EFFORT_DEFAULT_SEC.get(effort, 120) + + +def resolve_implementer_stream_timeout_sec(implementer: dict) -> int: + """Resolve implementer stream timeout in seconds. 0 = no timeout.""" + if implementer.get("streamTimeoutSec") is not None: + return max(0, int(implementer["streamTimeoutSec"])) + effort = (implementer.get("effort") or "").lower() + return _PLANNER_EFFORT_DEFAULT_SEC.get(effort, _IMPLEMENTER_DEFAULT_SEC) + + def load_config(project_root: str | None = None) -> dict: - """Load planforge.json for runtime commands (plan, implement, doctor). No template fallback. Raises if missing.""" + """Load planforge.json for runtime commands (plan, implement, doctor). Merges with template (default-*.json) by installed providers. Raises if missing.""" + from planforge.providers.claude import check_claude + from planforge.providers.codex import check_codex + cwd = project_root or str(Path.cwd()) root = get_project_root(cwd) config_path = Path(root) / "planforge.json" @@ -72,9 +90,10 @@ def load_config(project_root: str | None = None) -> dict: data = json.loads(config_path.read_text(encoding="utf-8")) except (json.JSONDecodeError, OSError) as e: raise RuntimeError("planforge.json is invalid.") from e + merge_base = get_default_config(check_claude(), check_codex()) planner = data.get("planner") or {} implementer = data.get("implementer") or {} return { - "planner": {**_MERGE_DEFAULTS["planner"], **planner, "provider": planner.get("provider", _MERGE_DEFAULTS["planner"]["provider"])}, - "implementer": {**_MERGE_DEFAULTS["implementer"], **implementer, "provider": implementer.get("provider", _MERGE_DEFAULTS["implementer"]["provider"])}, + "planner": {**merge_base["planner"], **planner, "provider": planner.get("provider", merge_base["planner"]["provider"])}, + "implementer": {**merge_base["implementer"], **implementer, "provider": implementer.get("provider", merge_base["implementer"]["provider"])}, } diff --git a/packages/cli-py/pyproject.toml b/packages/cli-py/pyproject.toml index 21d076b..79ce04e 100644 --- a/packages/cli-py/pyproject.toml +++ b/packages/cli-py/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "planforge" -version = "0.1.7" +version = "0.1.8" description = "PlanForge CLI - Bring your own AI to Cursor" requires-python = ">=3.10" dependencies = [ diff --git a/packages/core/package.json b/packages/core/package.json index ee01a64..67dfa88 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -1,6 +1,6 @@ { "name": "@planforge/core", - "version": "0.1.7", + "version": "0.1.8", "description": "PlanForge shared prompts", "license": "MIT", "files": ["prompts", "models.json"] diff --git a/packages/core/pyproject.toml b/packages/core/pyproject.toml index 0654239..086e152 100644 --- a/packages/core/pyproject.toml +++ b/packages/core/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "planforge-core" -version = "0.1.7" +version = "0.1.8" description = "PlanForge shared prompts" requires-python = ">=3.10" diff --git a/planforge.json b/planforge.json index 61e4699..2f5a193 100644 --- a/planforge.json +++ b/planforge.json @@ -1,8 +1,8 @@ { "planner": { "provider": "claude", - "model": "claude-opus-4-6", - "effort": "high" + "model": "claude-sonnet-4-6", + "effort": "medium" }, "implementer": { "provider": "codex", 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/cursor/rules/planforge-workflow.mdc b/templates/cursor/rules/planforge-workflow.mdc index 8a5e01d..f29435e 100644 --- a/templates/cursor/rules/planforge-workflow.mdc +++ b/templates/cursor/rules/planforge-workflow.mdc @@ -6,7 +6,7 @@ alwaysApply: true # PlanForge Workflow -- **When the user uses /p or asks for a plan/design/structure:** +- **When the user uses /p:** Using /p alone obliges you to run plan. Do not skip running `planforge plan` (or the plan script) by reclassifying the message as an "implementation request" based on its content. Treat the user's message as the goal and run the plan command first. Run **exactly one** of these with **no** `cd`, **no** `&&`, and no other prefix (terminal is already in workspace root): `planforge plan ""` or `.cursor/skills/p/scripts/run_plan.sh` (mac/Linux) or `.cursor/skills/p/scripts/run_plan.ps1` (Windows). Run it in the foreground so output streams in the agent terminal (sandbox). Do not write any plan/design output directly as a fallback. After the command succeeds, read `.cursor/plans/YYYY-MM-DD/{HHMM}-.plan.md` and summarize/reference it. If command execution fails, return the error and suggested fixes only. -- **When the user uses /i or asks for implementation:** +- **When the user uses /i:** Run **exactly one** command with **no** `cd`, **no** `&&`, and no other prefix (terminal is already in workspace root): `planforge implement ""` or `.cursor/skills/i/scripts/run_implement.sh` (mac/Linux) or `.cursor/skills/i/scripts/run_implement.ps1` (Windows). Run it in the foreground so output streams in the agent terminal (sandbox). Do not produce direct implementation output without running the command. Prefer the active plan in `.cursor/plans/index.json` (`activePlan`) or the latest `.plan.md`. After the command completes, on success summarize the result or suggest next steps; on failure return the error and suggested fixes only. If the command cannot be started, return the error and suggested fixes only. diff --git a/templates/cursor/skills/i/SKILL.md b/templates/cursor/skills/i/SKILL.md index 5b65679..7279ca5 100644 --- a/templates/cursor/skills/i/SKILL.md +++ b/templates/cursor/skills/i/SKILL.md @@ -12,7 +12,7 @@ You must execute the PlanForge command path for every `/i` request. Do not imple **When the user invokes /i with a prompt (e.g. "/i implement the login API"):** 1. **Summarize the conversation** - Summarize the current chat (goals, decisions, constraints, relevant files or tech) in a short paragraph and write/update a markdown file in the project's **context directory** (`.cursor/contexts`). Use a dated subdirectory and `HHMM-...` filename so plan/implement will read it, for example `.cursor/contexts/2026-03-10/1430-implement.md`. Keep it short and overwrite or append as needed. -2. **Run the implement script in the foreground (required)** - Run **one** command only: no `cd`, no `&&`. Terminal is already in workspace root. Use `.cursor/skills/i/scripts/run_implement.ps1 ""` on Windows or `.cursor/skills/i/scripts/run_implement.sh` on mac/Linux. Run it **in the foreground** so that output streams in the Cursor chat sandbox terminal; do not run in the background. Never write final code as a substitute for script execution. The command runs `planforge implement` using `planforge.json`. +2. **Run the implement script in the foreground (required)** - **Before** running the command, output a one-line advisory in the **same language as the user's message** (e.g. if the user wrote in Korean, write in Korean; if in English, in English). Example: "Expand the sandbox and check the output logs." Then run **one** command only: no `cd`, no `&&`. Terminal is already in workspace root. Use `.cursor/skills/i/scripts/run_implement.ps1 ""` on Windows or `.cursor/skills/i/scripts/run_implement.sh` on mac/Linux. Run it **in the foreground** so that output streams in the Cursor chat sandbox terminal; do not run in the background. Never write final code as a substitute for script execution. The command runs `planforge implement` using `planforge.json`. 3. **After it completes** - On success, summarize the result or suggest next steps. On failure, report the error output and suggest fixes. Do not produce implementation output in chat without having run the command. If script execution is blocked or fails, stop and return an error-focused response. Do not provide a substitute direct implementation. diff --git a/templates/cursor/skills/p/SKILL.md b/templates/cursor/skills/p/SKILL.md index 6b00397..1522e74 100644 --- a/templates/cursor/skills/p/SKILL.md +++ b/templates/cursor/skills/p/SKILL.md @@ -27,7 +27,7 @@ You must execute the PlanForge command path for every `/p` request. Do not draft ``` If you fetched URL content in step 0, append it as a separate section (e.g. `## Fetched URLs` or per-URL headings) in the **same** file. Create the dated subdirectory if it does not exist. -2. **Run the plan script in the foreground (required)** - Run **one** command only: no `cd`, no `&&` (PowerShell does not support `&&`). Terminal is already in workspace root. Use `.cursor/skills/p/scripts/run_plan.ps1 ""` on Windows or `.cursor/skills/p/scripts/run_plan.sh` on mac/Linux, or `planforge plan ""`. Run it **in the foreground** so that output streams in the Cursor chat sandbox terminal; do not run in the background. Pass the **same slug** so the plan output filename matches the context file: invoke as `planforge plan "" --slug ` (or ensure the script forwards `--slug `). The command generates `.cursor/plans/YYYY-MM-DD/{HHMM}-.plan.md` so that context file `HHMM-.md` and plan file `HHMM-.plan.md` use the same slug. Never create `.plan.md` content directly in chat. +2. **Run the plan script in the foreground (required)** - **Before** running the command, output a one-line advisory in the **same language as the user's message** (e.g. if the user wrote in Korean, write in Korean; if in English, in English). Example: "This may take a while. Please wait." Then run **one** command only: no `cd`, no `&&` (PowerShell does not support `&&`). Terminal is already in workspace root. Use `.cursor/skills/p/scripts/run_plan.ps1 ""` on Windows or `.cursor/skills/p/scripts/run_plan.sh` on mac/Linux, or `planforge plan ""`. Run it **in the foreground** so that output streams in the Cursor chat sandbox terminal; do not run in the background. Pass the **same slug** so the plan output filename matches the context file: invoke as `planforge plan "" --slug ` (or ensure the script forwards `--slug `). The command generates `.cursor/plans/YYYY-MM-DD/{HHMM}-.plan.md` so that context file `HHMM-.md` and plan file `HHMM-.plan.md` use the same slug. Never create `.plan.md` content directly in chat. 3. **After it completes** - Read the generated `.plan.md` file and summarize/reference it in your reply. Do not start implementation. If execution fails, report the error output and suggest concrete fixes (for example `planforge init` or installing the configured provider CLI). 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." }