planforge 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/commands/config.js +68 -0
- package/dist/commands/doctor.js +84 -0
- package/dist/commands/implement.js +204 -0
- package/dist/commands/init.js +267 -0
- package/dist/commands/install.js +18 -0
- package/dist/commands/plan.js +153 -0
- package/dist/config/load.js +17 -0
- package/dist/config/presets.js +31 -0
- package/dist/index.js +76 -0
- package/dist/providers/claude.js +164 -0
- package/dist/providers/codex.js +224 -0
- package/dist/providers/registry.js +42 -0
- package/dist/templates/install.js +34 -0
- package/dist/utils/active-plan.js +51 -0
- package/dist/utils/fs.js +15 -0
- package/dist/utils/paths.js +30 -0
- package/dist/utils/plan-files.js +42 -0
- package/dist/utils/project-context.js +30 -0
- package/dist/utils/repo-context.js +238 -0
- package/dist/utils/shell.js +47 -0
- package/package.json +38 -0
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* planforge plan <goal> - generate .cursor/plans/<summary>-<hash>.plan.md via configured planner provider
|
|
3
|
+
*/
|
|
4
|
+
import fs from "fs-extra";
|
|
5
|
+
import { resolve } from "path";
|
|
6
|
+
import { readFile } from "fs/promises";
|
|
7
|
+
import { randomBytes } from "crypto";
|
|
8
|
+
import { spawnSync } from "child_process";
|
|
9
|
+
import { romanize } from "@daun_jung/korean-romanizer";
|
|
10
|
+
import { getProjectRoot, getPlansDir } from "../utils/paths.js";
|
|
11
|
+
import { getRepoContext } from "../utils/repo-context.js";
|
|
12
|
+
import { getProjectContext } from "../utils/project-context.js";
|
|
13
|
+
import { loadConfig } from "../config/load.js";
|
|
14
|
+
import { getPlannerRunner } from "../providers/registry.js";
|
|
15
|
+
/** Characters disallowed in filenames on Windows / macOS / Linux */
|
|
16
|
+
const FILENAME_UNSAFE = /[\\/:*?"<>|]/g;
|
|
17
|
+
function isSlugValid(slug) {
|
|
18
|
+
return slug.length > 0 && !/^-+$/.test(slug);
|
|
19
|
+
}
|
|
20
|
+
/** ASCII-only slug (a-z, 0-9, -). Used for ASCII-only mode and after romanization. */
|
|
21
|
+
function slugifyAscii(text) {
|
|
22
|
+
const slug = text
|
|
23
|
+
.toLowerCase()
|
|
24
|
+
.trim()
|
|
25
|
+
.replace(/\s+/g, "-")
|
|
26
|
+
.replace(/[^a-z0-9-]/g, "")
|
|
27
|
+
.replace(/-+/g, "-")
|
|
28
|
+
.slice(0, 40);
|
|
29
|
+
return isSlugValid(slug) ? slug : "";
|
|
30
|
+
}
|
|
31
|
+
/** Slug that keeps Korean and Unicode; only removes filesystem-unsafe characters. */
|
|
32
|
+
function slugifyForFilename(text) {
|
|
33
|
+
const s = text
|
|
34
|
+
.trim()
|
|
35
|
+
.replace(FILENAME_UNSAFE, "")
|
|
36
|
+
.replace(/\s+/g, "-")
|
|
37
|
+
.replace(/-+/g, "-")
|
|
38
|
+
.replace(/^[-.\s]+|[-.\s]+$/g, "")
|
|
39
|
+
.slice(0, 40);
|
|
40
|
+
return isSlugValid(s) ? s : "";
|
|
41
|
+
}
|
|
42
|
+
function shortHash() {
|
|
43
|
+
return randomBytes(4).toString("hex");
|
|
44
|
+
}
|
|
45
|
+
/** Extract first # title or first non-empty line from plan markdown for use as slug source. */
|
|
46
|
+
function extractTitleFromPlanBody(planBody) {
|
|
47
|
+
const line = planBody
|
|
48
|
+
.split(/\r?\n/)
|
|
49
|
+
.map((l) => l.trim())
|
|
50
|
+
.find((l) => l.length > 0);
|
|
51
|
+
if (!line)
|
|
52
|
+
return "";
|
|
53
|
+
const match = line.match(/^#+\s*(.+)$/);
|
|
54
|
+
return (match ? match[1].trim() : line).slice(0, 80);
|
|
55
|
+
}
|
|
56
|
+
async function resolveContext(opts, cwd) {
|
|
57
|
+
if (!opts)
|
|
58
|
+
return undefined;
|
|
59
|
+
const parts = [];
|
|
60
|
+
if (opts.contextFile) {
|
|
61
|
+
const absPath = resolve(cwd, opts.contextFile);
|
|
62
|
+
try {
|
|
63
|
+
const content = await readFile(absPath, "utf-8");
|
|
64
|
+
if (content.trim())
|
|
65
|
+
parts.push(content.trim());
|
|
66
|
+
}
|
|
67
|
+
catch (err) {
|
|
68
|
+
console.error("Failed to read context file:", err.message);
|
|
69
|
+
process.exit(1);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
if (opts.context?.trim())
|
|
73
|
+
parts.push(opts.context.trim());
|
|
74
|
+
if (parts.length === 0)
|
|
75
|
+
return undefined;
|
|
76
|
+
return parts.join("\n\n");
|
|
77
|
+
}
|
|
78
|
+
export async function runPlan(args, opts) {
|
|
79
|
+
const goal = args.join(" ").trim();
|
|
80
|
+
if (!goal) {
|
|
81
|
+
console.error("Usage: planforge plan <goal>");
|
|
82
|
+
process.exit(1);
|
|
83
|
+
}
|
|
84
|
+
const cwd = process.cwd();
|
|
85
|
+
const projectRoot = getProjectRoot(cwd);
|
|
86
|
+
const context = await resolveContext(opts, cwd);
|
|
87
|
+
const config = await loadConfig(projectRoot);
|
|
88
|
+
const runner = getPlannerRunner(config.planner.provider);
|
|
89
|
+
if (!runner) {
|
|
90
|
+
console.error(`Unknown planner provider: ${config.planner.provider}. Check planforge.json.`);
|
|
91
|
+
process.exit(1);
|
|
92
|
+
}
|
|
93
|
+
if (!runner.check()) {
|
|
94
|
+
console.error(`${config.planner.provider} CLI not found. Install the provider CLI to use planforge plan.`);
|
|
95
|
+
process.exit(1);
|
|
96
|
+
}
|
|
97
|
+
const repoContext = getRepoContext(projectRoot, goal);
|
|
98
|
+
const projectContext = getProjectContext(projectRoot);
|
|
99
|
+
try {
|
|
100
|
+
const planBody = await runner.runPlan(goal, {
|
|
101
|
+
cwd: projectRoot,
|
|
102
|
+
context,
|
|
103
|
+
repoContext,
|
|
104
|
+
projectContext,
|
|
105
|
+
});
|
|
106
|
+
const asciiOnly = config.planner.asciiSlug ?? process.env.PLANFORGE_ASCII_SLUG === "1";
|
|
107
|
+
let slug = asciiOnly ? slugifyAscii(goal) : slugifyForFilename(goal);
|
|
108
|
+
if (!isSlugValid(slug)) {
|
|
109
|
+
try {
|
|
110
|
+
const romanized = romanize(goal);
|
|
111
|
+
slug = slugifyAscii(romanized);
|
|
112
|
+
}
|
|
113
|
+
catch {
|
|
114
|
+
/* ignore romanization errors */
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
if (!isSlugValid(slug)) {
|
|
118
|
+
const title = extractTitleFromPlanBody(planBody);
|
|
119
|
+
if (title) {
|
|
120
|
+
slug = asciiOnly ? slugifyAscii(title) : slugifyForFilename(title);
|
|
121
|
+
if (!isSlugValid(slug)) {
|
|
122
|
+
try {
|
|
123
|
+
slug = slugifyAscii(romanize(title));
|
|
124
|
+
}
|
|
125
|
+
catch {
|
|
126
|
+
/* ignore */
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
if (!isSlugValid(slug)) {
|
|
132
|
+
slug = "plan";
|
|
133
|
+
}
|
|
134
|
+
const hash = shortHash();
|
|
135
|
+
const plansDir = getPlansDir(projectRoot);
|
|
136
|
+
await fs.ensureDir(plansDir);
|
|
137
|
+
const filename = `${slug}-${hash}.plan.md`;
|
|
138
|
+
const filePath = resolve(plansDir, filename);
|
|
139
|
+
await fs.writeFile(filePath, planBody, "utf-8");
|
|
140
|
+
console.log("Created:", filePath);
|
|
141
|
+
// Open the plan file in Cursor for review (user can then run /i when ready)
|
|
142
|
+
try {
|
|
143
|
+
spawnSync("cursor", [filePath], { stdio: "ignore", windowsHide: true });
|
|
144
|
+
}
|
|
145
|
+
catch {
|
|
146
|
+
/* cursor not in PATH or open failed; leave as-is */
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
catch (err) {
|
|
150
|
+
console.error("Plan generation failed:", err.message);
|
|
151
|
+
process.exit(1);
|
|
152
|
+
}
|
|
153
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Load planforge.json or fall back to preset for current providers.
|
|
3
|
+
*/
|
|
4
|
+
import fs from "fs-extra";
|
|
5
|
+
import { resolve } from "path";
|
|
6
|
+
import { getPresetForProviders } from "./presets.js";
|
|
7
|
+
import { checkClaude } from "../providers/claude.js";
|
|
8
|
+
import { checkCodex } from "../providers/codex.js";
|
|
9
|
+
export async function loadConfig(projectRoot) {
|
|
10
|
+
const configPath = resolve(projectRoot, "planforge.json");
|
|
11
|
+
if (await fs.pathExists(configPath)) {
|
|
12
|
+
return (await fs.readJson(configPath));
|
|
13
|
+
}
|
|
14
|
+
const hasClaude = checkClaude();
|
|
15
|
+
const hasCodex = checkCodex();
|
|
16
|
+
return getPresetForProviders(hasClaude, hasCodex);
|
|
17
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* planforge.json presets per provider combination (both / claude-only / codex-only)
|
|
3
|
+
*/
|
|
4
|
+
const PRESET_BOTH = {
|
|
5
|
+
planner: { provider: "claude", model: "claude-opus-4-6", effort: "high" },
|
|
6
|
+
implementer: { provider: "codex", model: "gpt-5.4" },
|
|
7
|
+
plansDir: ".cursor/plans",
|
|
8
|
+
};
|
|
9
|
+
const PRESET_CLAUDE_ONLY = {
|
|
10
|
+
planner: { provider: "claude", model: "claude-opus-4-6", effort: "high" },
|
|
11
|
+
implementer: { provider: "claude", model: "claude-sonnet-4-6", effort: "medium" },
|
|
12
|
+
plansDir: ".cursor/plans",
|
|
13
|
+
};
|
|
14
|
+
const PRESET_CODEX_ONLY = {
|
|
15
|
+
planner: { provider: "codex", model: "gpt-5.4", reasoning: "high" },
|
|
16
|
+
implementer: { provider: "codex", model: "gpt-5.4", reasoning: "low" },
|
|
17
|
+
plansDir: ".cursor/plans",
|
|
18
|
+
};
|
|
19
|
+
/**
|
|
20
|
+
* Return the recommended preset for the given installed providers.
|
|
21
|
+
* Both -> p=Claude Opus 4.6, i=Codex 5.4; Claude only -> p=Opus, i=Sonnet; Codex only -> p/i=GPT-5.4 with different reasoning.
|
|
22
|
+
*/
|
|
23
|
+
export function getPresetForProviders(hasClaude, hasCodex) {
|
|
24
|
+
if (hasClaude && hasCodex)
|
|
25
|
+
return PRESET_BOTH;
|
|
26
|
+
if (hasClaude)
|
|
27
|
+
return PRESET_CLAUDE_ONLY;
|
|
28
|
+
if (hasCodex)
|
|
29
|
+
return PRESET_CODEX_ONLY;
|
|
30
|
+
return PRESET_CLAUDE_ONLY;
|
|
31
|
+
}
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* PlanForge CLI entry point.
|
|
4
|
+
*/
|
|
5
|
+
import { Command } from "commander";
|
|
6
|
+
import { runInit } from "./commands/init.js";
|
|
7
|
+
import { runDoctor } from "./commands/doctor.js";
|
|
8
|
+
import { runInstall } from "./commands/install.js";
|
|
9
|
+
import { runPlan } from "./commands/plan.js";
|
|
10
|
+
import { runImplement } from "./commands/implement.js";
|
|
11
|
+
import { runConfigShow, runConfigSuggest } from "./commands/config.js";
|
|
12
|
+
const program = new Command();
|
|
13
|
+
program
|
|
14
|
+
.name("planforge")
|
|
15
|
+
.description("Bring your own AI to Cursor. Use Claude or Codex inside Cursor Free.")
|
|
16
|
+
.version("0.1.0");
|
|
17
|
+
program
|
|
18
|
+
.command("init")
|
|
19
|
+
.description("Detect providers, run claude /init, create AGENTS.md, install Cursor slash commands, create .cursor/plans and planforge.json")
|
|
20
|
+
.option("--skip-provider-install", "Skip interactive provider (Claude/Codex) install prompt")
|
|
21
|
+
.action(async (opts) => {
|
|
22
|
+
await runInit(opts.skipProviderInstall ? ["--skip-provider-install"] : []);
|
|
23
|
+
});
|
|
24
|
+
program
|
|
25
|
+
.command("doctor")
|
|
26
|
+
.description("Check environment: Claude CLI, Codex CLI, CLAUDE.md, AGENTS.md, planforge.json, .cursor/plans")
|
|
27
|
+
.action(async () => {
|
|
28
|
+
await runDoctor([]);
|
|
29
|
+
});
|
|
30
|
+
program
|
|
31
|
+
.command("install")
|
|
32
|
+
.description("Install Cursor slash commands and templates to .cursor/skills and .cursor/rules")
|
|
33
|
+
.option("-f, --force", "Overwrite existing planforge.json")
|
|
34
|
+
.action(async (opts) => {
|
|
35
|
+
await runInstall(opts.force ? ["--force"] : []);
|
|
36
|
+
});
|
|
37
|
+
program
|
|
38
|
+
.command("plan")
|
|
39
|
+
.description("Generate a development plan and save to .cursor/plans (uses planner from planforge.json)")
|
|
40
|
+
.argument("[goal...]", "Planning goal (e.g. design auth refresh token)")
|
|
41
|
+
.option("--context-file <path>", "Path to conversation context file (e.g. .cursor/chat-context.txt)")
|
|
42
|
+
.option("--context <text>", "Conversation context text to pass to the planner")
|
|
43
|
+
.action(async (goalParts, opts) => {
|
|
44
|
+
await runPlan(goalParts, opts);
|
|
45
|
+
});
|
|
46
|
+
program
|
|
47
|
+
.command("implement")
|
|
48
|
+
.description("Run implementation (uses implementer from planforge.json)")
|
|
49
|
+
.argument("[prompt...]", "Implementation prompt or task")
|
|
50
|
+
.option("--context-file <path>", "Path to conversation context file (e.g. .cursor/chat-context.txt)")
|
|
51
|
+
.option("--context <text>", "Conversation context text to pass to the implementer")
|
|
52
|
+
.option("--plan-file <path>", "Path to plan file (default: index.json activePlan or latest .plan.md)")
|
|
53
|
+
.option("--files <paths...>", "File paths to focus on (overrides plan's Files Likely to Change)")
|
|
54
|
+
.action(async (promptParts, opts) => {
|
|
55
|
+
await runImplement(promptParts, opts);
|
|
56
|
+
});
|
|
57
|
+
const configCmd = program
|
|
58
|
+
.command("config")
|
|
59
|
+
.description("Show or suggest planforge.json config")
|
|
60
|
+
.action(async () => {
|
|
61
|
+
await runConfigShow([]);
|
|
62
|
+
});
|
|
63
|
+
configCmd
|
|
64
|
+
.command("show")
|
|
65
|
+
.description("Show current planforge.json")
|
|
66
|
+
.action(async () => {
|
|
67
|
+
await runConfigShow([]);
|
|
68
|
+
});
|
|
69
|
+
configCmd
|
|
70
|
+
.command("suggest")
|
|
71
|
+
.description("Suggest config for your installed providers (Current vs Suggested)")
|
|
72
|
+
.option("--apply", "Write suggested config to planforge.json")
|
|
73
|
+
.action(async (opts) => {
|
|
74
|
+
await runConfigSuggest(opts.apply ? ["--apply"] : []);
|
|
75
|
+
});
|
|
76
|
+
program.parse();
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Claude provider - planning (e.g. /p)
|
|
3
|
+
*/
|
|
4
|
+
import { execSync, spawn } from "child_process";
|
|
5
|
+
import { readFile } from "fs/promises";
|
|
6
|
+
import { resolve, dirname } from "path";
|
|
7
|
+
import { hasCommand } from "../utils/shell.js";
|
|
8
|
+
import { getTemplatesRoot } from "../utils/paths.js";
|
|
9
|
+
/** npm package for global install: npm install -g @anthropic-ai/claude-code */
|
|
10
|
+
export const CLIENT_NPM_PACKAGE = "@anthropic-ai/claude-code";
|
|
11
|
+
export function checkClaude() {
|
|
12
|
+
return hasCommand("claude");
|
|
13
|
+
}
|
|
14
|
+
/**
|
|
15
|
+
* Run Claude to generate a plan. Returns plan markdown.
|
|
16
|
+
*/
|
|
17
|
+
export async function runPlan(goal, opts) {
|
|
18
|
+
const cwd = opts?.cwd ?? process.cwd();
|
|
19
|
+
const templatesRoot = getTemplatesRoot();
|
|
20
|
+
const repoRoot = dirname(templatesRoot);
|
|
21
|
+
const defaultPromptPath = resolve(repoRoot, "packages", "core", "prompts", "planner-system.md");
|
|
22
|
+
let fullPrompt;
|
|
23
|
+
try {
|
|
24
|
+
const systemPrompt = await readFile(opts?.systemPromptPath ?? defaultPromptPath, "utf-8");
|
|
25
|
+
let body = systemPrompt.trim();
|
|
26
|
+
if (opts?.projectContext?.trim()) {
|
|
27
|
+
body += "\n\n---\n\nProject context (AGENTS.md):\n" + opts.projectContext.trim();
|
|
28
|
+
}
|
|
29
|
+
if (opts?.repoContext?.trim()) {
|
|
30
|
+
body += "\n\n---\n\nRepository context:\n" + opts.repoContext.trim();
|
|
31
|
+
}
|
|
32
|
+
if (opts?.context?.trim()) {
|
|
33
|
+
body += "\n\n---\n\nConversation context:\n" + opts.context.trim();
|
|
34
|
+
}
|
|
35
|
+
fullPrompt = body + "\n\n---\n\nUser goal: " + goal;
|
|
36
|
+
}
|
|
37
|
+
catch {
|
|
38
|
+
let fallback = "Produce a development plan with sections: Goal, Assumptions, Relevant Codebase Areas, Proposed Changes, Step-by-Step Plan, Files Likely to Change, Risks, Validation Checklist.";
|
|
39
|
+
if (opts?.projectContext?.trim()) {
|
|
40
|
+
fallback += "\n\n---\n\nProject context (AGENTS.md):\n" + opts.projectContext.trim();
|
|
41
|
+
}
|
|
42
|
+
if (opts?.repoContext?.trim()) {
|
|
43
|
+
fallback += "\n\n---\n\nRepository context:\n" + opts.repoContext.trim();
|
|
44
|
+
}
|
|
45
|
+
if (opts?.context?.trim()) {
|
|
46
|
+
fallback += "\n\n---\n\nConversation context:\n" + opts.context.trim();
|
|
47
|
+
}
|
|
48
|
+
fullPrompt = fallback + "\n\n---\n\nUser goal: " + goal;
|
|
49
|
+
}
|
|
50
|
+
try {
|
|
51
|
+
const out = execSync("claude", {
|
|
52
|
+
encoding: "utf-8",
|
|
53
|
+
input: fullPrompt,
|
|
54
|
+
cwd,
|
|
55
|
+
maxBuffer: 1024 * 1024,
|
|
56
|
+
});
|
|
57
|
+
return typeof out === "string" ? out.trim() : String(out).trim();
|
|
58
|
+
}
|
|
59
|
+
catch (err) {
|
|
60
|
+
const msg = err.stdout
|
|
61
|
+
?? err.stderr
|
|
62
|
+
?? err.message;
|
|
63
|
+
throw new Error("Claude plan failed: " + msg);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
const DEFAULT_IMPLEMENTER_FALLBACK = "Implement the user request. Produce code or concrete changes as requested.";
|
|
67
|
+
/**
|
|
68
|
+
* Run Claude to perform implementation. Returns implementation output (e.g. code or instructions).
|
|
69
|
+
*/
|
|
70
|
+
export async function runImplement(prompt, opts) {
|
|
71
|
+
const cwd = opts?.cwd ?? process.cwd();
|
|
72
|
+
const templatesRoot = getTemplatesRoot();
|
|
73
|
+
const repoRoot = dirname(templatesRoot);
|
|
74
|
+
const defaultPromptPath = resolve(repoRoot, "packages", "core", "prompts", "implementer-system.md");
|
|
75
|
+
let fullPrompt;
|
|
76
|
+
try {
|
|
77
|
+
const systemPrompt = await readFile(opts?.systemPromptPath ?? defaultPromptPath, "utf-8");
|
|
78
|
+
let body = systemPrompt.trim();
|
|
79
|
+
if (opts?.projectContext?.trim()) {
|
|
80
|
+
body += "\n\n---\n\nProject context (AGENTS.md):\n" + opts.projectContext.trim();
|
|
81
|
+
}
|
|
82
|
+
if (opts?.context?.trim()) {
|
|
83
|
+
body += "\n\n---\n\nConversation context:\n" + opts.context.trim();
|
|
84
|
+
}
|
|
85
|
+
if (opts?.planContent?.trim()) {
|
|
86
|
+
body += "\n\n---\n\nCurrent plan (follow this):\n" + opts.planContent.trim();
|
|
87
|
+
}
|
|
88
|
+
if (opts?.filesToChange?.length) {
|
|
89
|
+
body += "\n\n---\n\nFiles to focus on:\n" + opts.filesToChange.join("\n");
|
|
90
|
+
}
|
|
91
|
+
if (opts?.recentCommitsPerFile?.trim()) {
|
|
92
|
+
body += "\n\n---\n\nRecent commit (per file):\n" + opts.recentCommitsPerFile.trim();
|
|
93
|
+
}
|
|
94
|
+
if (opts?.codeContext?.trim()) {
|
|
95
|
+
body += "\n\n---\n\nRelevant file contents:\n" + opts.codeContext.trim();
|
|
96
|
+
}
|
|
97
|
+
fullPrompt = body + "\n\n---\n\nUser request: " + prompt;
|
|
98
|
+
}
|
|
99
|
+
catch {
|
|
100
|
+
let fallback = DEFAULT_IMPLEMENTER_FALLBACK;
|
|
101
|
+
if (opts?.projectContext?.trim()) {
|
|
102
|
+
fallback += "\n\n---\n\nProject context (AGENTS.md):\n" + opts.projectContext.trim();
|
|
103
|
+
}
|
|
104
|
+
if (opts?.context?.trim()) {
|
|
105
|
+
fallback += "\n\n---\n\nConversation context:\n" + opts.context.trim();
|
|
106
|
+
}
|
|
107
|
+
if (opts?.planContent?.trim()) {
|
|
108
|
+
fallback += "\n\n---\n\nCurrent plan (follow this):\n" + opts.planContent.trim();
|
|
109
|
+
}
|
|
110
|
+
if (opts?.filesToChange?.length) {
|
|
111
|
+
fallback += "\n\n---\n\nFiles to focus on:\n" + opts.filesToChange.join("\n");
|
|
112
|
+
}
|
|
113
|
+
if (opts?.recentCommitsPerFile?.trim()) {
|
|
114
|
+
fallback += "\n\n---\n\nRecent commit (per file):\n" + opts.recentCommitsPerFile.trim();
|
|
115
|
+
}
|
|
116
|
+
if (opts?.codeContext?.trim()) {
|
|
117
|
+
fallback += "\n\n---\n\nRelevant file contents:\n" + opts.codeContext.trim();
|
|
118
|
+
}
|
|
119
|
+
fullPrompt = fallback + "\n\n---\n\nUser request: " + prompt;
|
|
120
|
+
}
|
|
121
|
+
try {
|
|
122
|
+
return await runClaudeStreaming(fullPrompt, cwd);
|
|
123
|
+
}
|
|
124
|
+
catch (err) {
|
|
125
|
+
const msg = err.stdout
|
|
126
|
+
?? err.stderr
|
|
127
|
+
?? err.message;
|
|
128
|
+
throw new Error("Claude implement failed: " + msg);
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
/**
|
|
132
|
+
* Run Claude with streaming: forward stdout/stderr to the current process so the user
|
|
133
|
+
* sees logs in real time (e.g. in Cursor chat terminal). Returns collected stdout when done.
|
|
134
|
+
*/
|
|
135
|
+
function runClaudeStreaming(fullPrompt, cwd) {
|
|
136
|
+
return new Promise((resolve, reject) => {
|
|
137
|
+
const chunks = [];
|
|
138
|
+
const child = spawn("claude", [], {
|
|
139
|
+
cwd,
|
|
140
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
141
|
+
});
|
|
142
|
+
child.stdin?.write(fullPrompt, "utf-8", (err) => {
|
|
143
|
+
if (err)
|
|
144
|
+
reject(err);
|
|
145
|
+
else
|
|
146
|
+
child.stdin?.end();
|
|
147
|
+
});
|
|
148
|
+
child.stdout?.on("data", (chunk) => {
|
|
149
|
+
chunks.push(chunk);
|
|
150
|
+
process.stdout.write(chunk);
|
|
151
|
+
});
|
|
152
|
+
child.stderr?.on("data", (chunk) => {
|
|
153
|
+
process.stderr.write(chunk);
|
|
154
|
+
});
|
|
155
|
+
child.on("close", (code) => {
|
|
156
|
+
if (code !== 0) {
|
|
157
|
+
reject(new Error("Claude exited with code " + code));
|
|
158
|
+
return;
|
|
159
|
+
}
|
|
160
|
+
resolve(Buffer.concat(chunks).toString("utf-8").trim());
|
|
161
|
+
});
|
|
162
|
+
child.on("error", (err) => reject(err));
|
|
163
|
+
});
|
|
164
|
+
}
|
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Codex provider - planning (/p) and implementation (/i)
|
|
3
|
+
*/
|
|
4
|
+
import { randomBytes } from "crypto";
|
|
5
|
+
import { spawnSync, spawn } from "child_process";
|
|
6
|
+
import { writeFileSync, unlinkSync } from "fs";
|
|
7
|
+
import { tmpdir } from "os";
|
|
8
|
+
import { readFile } from "fs/promises";
|
|
9
|
+
import { resolve, dirname, join } from "path";
|
|
10
|
+
import { hasCommand } from "../utils/shell.js";
|
|
11
|
+
import { getTemplatesRoot } from "../utils/paths.js";
|
|
12
|
+
/** npm package for global install: npm install -g @openai/codex */
|
|
13
|
+
export const CLIENT_NPM_PACKAGE = "@openai/codex";
|
|
14
|
+
export function checkCodex() {
|
|
15
|
+
return hasCommand("codex");
|
|
16
|
+
}
|
|
17
|
+
function getRepoRoot() {
|
|
18
|
+
return dirname(getTemplatesRoot());
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* Run "codex exec" with the given prompt. On Windows uses temp file + PowerShell to avoid
|
|
22
|
+
* EINVAL from spawning .cmd directly (CVE-2024-27980) and to avoid shell splitting long args.
|
|
23
|
+
*/
|
|
24
|
+
function runCodexExec(fullPrompt, cwd) {
|
|
25
|
+
const opts = { cwd, encoding: "utf-8", maxBuffer: 1024 * 1024 };
|
|
26
|
+
if (process.platform === "win32") {
|
|
27
|
+
const tempPath = join(tmpdir(), "planforge-" + randomBytes(8).toString("hex") + ".txt");
|
|
28
|
+
try {
|
|
29
|
+
writeFileSync(tempPath, fullPrompt, "utf-8");
|
|
30
|
+
const escapedPath = tempPath.replace(/'/g, "''");
|
|
31
|
+
const script = `Get-Content -Raw -LiteralPath '${escapedPath}' | codex exec -`;
|
|
32
|
+
const result = spawnSync("powershell", ["-NoProfile", "-Command", script], opts);
|
|
33
|
+
if (result.status !== 0) {
|
|
34
|
+
const msg = result.stderr ?? result.stdout ?? result.error?.message ?? "Codex exited non-zero";
|
|
35
|
+
throw new Error(String(msg));
|
|
36
|
+
}
|
|
37
|
+
return (result.stdout ?? "").trim();
|
|
38
|
+
}
|
|
39
|
+
finally {
|
|
40
|
+
try {
|
|
41
|
+
unlinkSync(tempPath);
|
|
42
|
+
}
|
|
43
|
+
catch {
|
|
44
|
+
// ignore cleanup failure
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
const result = spawnSync("codex", ["exec", fullPrompt], { ...opts, shell: false });
|
|
49
|
+
if (result.status !== 0) {
|
|
50
|
+
const msg = result.stderr ?? result.stdout ?? result.error?.message ?? "Codex exited non-zero";
|
|
51
|
+
throw new Error(String(msg));
|
|
52
|
+
}
|
|
53
|
+
return (result.stdout ?? "").trim();
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* Run "codex exec" with streaming: forward stdout/stderr to the current process so the user
|
|
57
|
+
* sees logs in real time (e.g. in Cursor chat terminal). Returns collected stdout when done.
|
|
58
|
+
*/
|
|
59
|
+
function runCodexExecStreaming(fullPrompt, cwd) {
|
|
60
|
+
return new Promise((resolve, reject) => {
|
|
61
|
+
const chunks = [];
|
|
62
|
+
const opts = { cwd };
|
|
63
|
+
if (process.platform === "win32") {
|
|
64
|
+
const tempPath = join(tmpdir(), "planforge-" + randomBytes(8).toString("hex") + ".txt");
|
|
65
|
+
writeFileSync(tempPath, fullPrompt, "utf-8");
|
|
66
|
+
const escapedPath = tempPath.replace(/'/g, "''");
|
|
67
|
+
const script = `Get-Content -Raw -LiteralPath '${escapedPath}' | codex exec -`;
|
|
68
|
+
const child = spawn("powershell", ["-NoProfile", "-Command", script], {
|
|
69
|
+
...opts,
|
|
70
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
71
|
+
});
|
|
72
|
+
child.on("close", (code) => {
|
|
73
|
+
try {
|
|
74
|
+
unlinkSync(tempPath);
|
|
75
|
+
}
|
|
76
|
+
catch {
|
|
77
|
+
// ignore
|
|
78
|
+
}
|
|
79
|
+
if (code !== 0) {
|
|
80
|
+
reject(new Error("Codex exited with code " + code));
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
resolve(Buffer.concat(chunks).toString("utf-8").trim());
|
|
84
|
+
});
|
|
85
|
+
child.stdout?.on("data", (chunk) => {
|
|
86
|
+
chunks.push(chunk);
|
|
87
|
+
process.stdout.write(chunk);
|
|
88
|
+
});
|
|
89
|
+
child.stderr?.on("data", (chunk) => {
|
|
90
|
+
process.stderr.write(chunk);
|
|
91
|
+
});
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
const child = spawn("codex", ["exec", fullPrompt], {
|
|
95
|
+
...opts,
|
|
96
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
97
|
+
});
|
|
98
|
+
child.stdout?.on("data", (chunk) => {
|
|
99
|
+
chunks.push(chunk);
|
|
100
|
+
process.stdout.write(chunk);
|
|
101
|
+
});
|
|
102
|
+
child.stderr?.on("data", (chunk) => {
|
|
103
|
+
process.stderr.write(chunk);
|
|
104
|
+
});
|
|
105
|
+
child.on("close", (code) => {
|
|
106
|
+
if (code !== 0) {
|
|
107
|
+
reject(new Error("Codex exited with code " + code));
|
|
108
|
+
return;
|
|
109
|
+
}
|
|
110
|
+
resolve(Buffer.concat(chunks).toString("utf-8").trim());
|
|
111
|
+
});
|
|
112
|
+
child.on("error", (err) => reject(err));
|
|
113
|
+
});
|
|
114
|
+
}
|
|
115
|
+
const DEFAULT_PLANNER_FALLBACK = "Produce a development plan with sections: Goal, Assumptions, Relevant Codebase Areas, Proposed Changes, Step-by-Step Plan, Files Likely to Change, Risks, Validation Checklist.";
|
|
116
|
+
/**
|
|
117
|
+
* Run Codex to generate a plan. Returns plan markdown.
|
|
118
|
+
*/
|
|
119
|
+
export async function runPlan(goal, opts) {
|
|
120
|
+
const cwd = opts?.cwd ?? process.cwd();
|
|
121
|
+
const repoRoot = getRepoRoot();
|
|
122
|
+
const defaultPromptPath = resolve(repoRoot, "packages", "core", "prompts", "planner-system.md");
|
|
123
|
+
let fullPrompt;
|
|
124
|
+
try {
|
|
125
|
+
const systemPrompt = await readFile(opts?.systemPromptPath ?? defaultPromptPath, "utf-8");
|
|
126
|
+
let body = systemPrompt.trim();
|
|
127
|
+
if (opts?.projectContext?.trim()) {
|
|
128
|
+
body += "\n\n---\n\nProject context (AGENTS.md):\n" + opts.projectContext.trim();
|
|
129
|
+
}
|
|
130
|
+
if (opts?.repoContext?.trim()) {
|
|
131
|
+
body += "\n\n---\n\nRepository context:\n" + opts.repoContext.trim();
|
|
132
|
+
}
|
|
133
|
+
if (opts?.context?.trim()) {
|
|
134
|
+
body += "\n\n---\n\nConversation context:\n" + opts.context.trim();
|
|
135
|
+
}
|
|
136
|
+
fullPrompt = body + "\n\n---\n\nUser goal: " + goal;
|
|
137
|
+
}
|
|
138
|
+
catch {
|
|
139
|
+
let fallback = DEFAULT_PLANNER_FALLBACK;
|
|
140
|
+
if (opts?.projectContext?.trim()) {
|
|
141
|
+
fallback += "\n\n---\n\nProject context (AGENTS.md):\n" + opts.projectContext.trim();
|
|
142
|
+
}
|
|
143
|
+
if (opts?.repoContext?.trim()) {
|
|
144
|
+
fallback += "\n\n---\n\nRepository context:\n" + opts.repoContext.trim();
|
|
145
|
+
}
|
|
146
|
+
if (opts?.context?.trim()) {
|
|
147
|
+
fallback += "\n\n---\n\nConversation context:\n" + opts.context.trim();
|
|
148
|
+
}
|
|
149
|
+
fullPrompt = fallback + "\n\n---\n\nUser goal: " + goal;
|
|
150
|
+
}
|
|
151
|
+
try {
|
|
152
|
+
return runCodexExec(fullPrompt, cwd);
|
|
153
|
+
}
|
|
154
|
+
catch (err) {
|
|
155
|
+
const msg = err.stdout
|
|
156
|
+
?? err.stderr
|
|
157
|
+
?? err.message;
|
|
158
|
+
throw new Error("Codex plan failed: " + msg);
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
const DEFAULT_IMPLEMENTER_FALLBACK = "Implement the user request. Produce code or concrete changes as requested.";
|
|
162
|
+
/**
|
|
163
|
+
* Run Codex to perform implementation. Returns implementation output.
|
|
164
|
+
*/
|
|
165
|
+
export async function runImplement(prompt, opts) {
|
|
166
|
+
const cwd = opts?.cwd ?? process.cwd();
|
|
167
|
+
const repoRoot = getRepoRoot();
|
|
168
|
+
const defaultPromptPath = resolve(repoRoot, "packages", "core", "prompts", "implementer-system.md");
|
|
169
|
+
let fullPrompt;
|
|
170
|
+
try {
|
|
171
|
+
const systemPrompt = await readFile(opts?.systemPromptPath ?? defaultPromptPath, "utf-8");
|
|
172
|
+
let body = systemPrompt.trim();
|
|
173
|
+
if (opts?.projectContext?.trim()) {
|
|
174
|
+
body += "\n\n---\n\nProject context (AGENTS.md):\n" + opts.projectContext.trim();
|
|
175
|
+
}
|
|
176
|
+
if (opts?.context?.trim()) {
|
|
177
|
+
body += "\n\n---\n\nConversation context:\n" + opts.context.trim();
|
|
178
|
+
}
|
|
179
|
+
if (opts?.planContent?.trim()) {
|
|
180
|
+
body += "\n\n---\n\nCurrent plan (follow this):\n" + opts.planContent.trim();
|
|
181
|
+
}
|
|
182
|
+
if (opts?.filesToChange?.length) {
|
|
183
|
+
body += "\n\n---\n\nFiles to focus on:\n" + opts.filesToChange.join("\n");
|
|
184
|
+
}
|
|
185
|
+
if (opts?.recentCommitsPerFile?.trim()) {
|
|
186
|
+
body += "\n\n---\n\nRecent commit (per file):\n" + opts.recentCommitsPerFile.trim();
|
|
187
|
+
}
|
|
188
|
+
if (opts?.codeContext?.trim()) {
|
|
189
|
+
body += "\n\n---\n\nRelevant file contents:\n" + opts.codeContext.trim();
|
|
190
|
+
}
|
|
191
|
+
fullPrompt = body + "\n\n---\n\nUser request: " + prompt;
|
|
192
|
+
}
|
|
193
|
+
catch {
|
|
194
|
+
let fallback = DEFAULT_IMPLEMENTER_FALLBACK;
|
|
195
|
+
if (opts?.projectContext?.trim()) {
|
|
196
|
+
fallback += "\n\n---\n\nProject context (AGENTS.md):\n" + opts.projectContext.trim();
|
|
197
|
+
}
|
|
198
|
+
if (opts?.context?.trim()) {
|
|
199
|
+
fallback += "\n\n---\n\nConversation context:\n" + opts.context.trim();
|
|
200
|
+
}
|
|
201
|
+
if (opts?.planContent?.trim()) {
|
|
202
|
+
fallback += "\n\n---\n\nCurrent plan (follow this):\n" + opts.planContent.trim();
|
|
203
|
+
}
|
|
204
|
+
if (opts?.filesToChange?.length) {
|
|
205
|
+
fallback += "\n\n---\n\nFiles to focus on:\n" + opts.filesToChange.join("\n");
|
|
206
|
+
}
|
|
207
|
+
if (opts?.recentCommitsPerFile?.trim()) {
|
|
208
|
+
fallback += "\n\n---\n\nRecent commit (per file):\n" + opts.recentCommitsPerFile.trim();
|
|
209
|
+
}
|
|
210
|
+
if (opts?.codeContext?.trim()) {
|
|
211
|
+
fallback += "\n\n---\n\nRelevant file contents:\n" + opts.codeContext.trim();
|
|
212
|
+
}
|
|
213
|
+
fullPrompt = fallback + "\n\n---\n\nUser request: " + prompt;
|
|
214
|
+
}
|
|
215
|
+
try {
|
|
216
|
+
return await runCodexExecStreaming(fullPrompt, cwd);
|
|
217
|
+
}
|
|
218
|
+
catch (err) {
|
|
219
|
+
const msg = err.stdout
|
|
220
|
+
?? err.stderr
|
|
221
|
+
?? err.message;
|
|
222
|
+
throw new Error("Codex implement failed: " + msg);
|
|
223
|
+
}
|
|
224
|
+
}
|