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,68 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* planforge config - show or suggest planforge.json
|
|
3
|
+
*/
|
|
4
|
+
import fs from "fs-extra";
|
|
5
|
+
import { resolve } from "path";
|
|
6
|
+
import { getProjectRoot } from "../utils/paths.js";
|
|
7
|
+
import { checkClaude } from "../providers/claude.js";
|
|
8
|
+
import { checkCodex } from "../providers/codex.js";
|
|
9
|
+
import { getPresetForProviders, } from "../config/presets.js";
|
|
10
|
+
export function formatRole(config, role) {
|
|
11
|
+
const r = config[role];
|
|
12
|
+
const parts = [r.provider, r.model];
|
|
13
|
+
if (r.effort)
|
|
14
|
+
parts.push(`effort:${r.effort}`);
|
|
15
|
+
if (r.reasoning)
|
|
16
|
+
parts.push(`reasoning:${r.reasoning}`);
|
|
17
|
+
return parts.join(" / ");
|
|
18
|
+
}
|
|
19
|
+
export function configEqual(a, b) {
|
|
20
|
+
return (JSON.stringify(a.planner) === JSON.stringify(b.planner) &&
|
|
21
|
+
JSON.stringify(a.implementer) === JSON.stringify(b.implementer));
|
|
22
|
+
}
|
|
23
|
+
export async function runConfigShow(_args) {
|
|
24
|
+
const projectRoot = getProjectRoot();
|
|
25
|
+
const configPath = resolve(projectRoot, "planforge.json");
|
|
26
|
+
if (!(await fs.pathExists(configPath))) {
|
|
27
|
+
console.log("Run planforge init first.");
|
|
28
|
+
return;
|
|
29
|
+
}
|
|
30
|
+
const content = await fs.readFile(configPath, "utf-8");
|
|
31
|
+
console.log(content);
|
|
32
|
+
}
|
|
33
|
+
export async function runConfigSuggest(args) {
|
|
34
|
+
const apply = args.includes("--apply");
|
|
35
|
+
const projectRoot = getProjectRoot();
|
|
36
|
+
const configPath = resolve(projectRoot, "planforge.json");
|
|
37
|
+
const hasClaude = checkClaude();
|
|
38
|
+
const hasCodex = checkCodex();
|
|
39
|
+
const suggested = getPresetForProviders(hasClaude, hasCodex);
|
|
40
|
+
if (!(await fs.pathExists(configPath))) {
|
|
41
|
+
console.log("No planforge.json found. Suggested config for your installed providers:\n");
|
|
42
|
+
console.log(JSON.stringify(suggested, null, 2));
|
|
43
|
+
if (apply) {
|
|
44
|
+
await fs.writeJson(configPath, suggested, { spaces: 2 });
|
|
45
|
+
console.log("\nCreated planforge.json");
|
|
46
|
+
}
|
|
47
|
+
return;
|
|
48
|
+
}
|
|
49
|
+
const current = (await fs.readJson(configPath));
|
|
50
|
+
if (configEqual(current, suggested)) {
|
|
51
|
+
console.log("Your planforge.json already matches the recommended config for your providers.");
|
|
52
|
+
return;
|
|
53
|
+
}
|
|
54
|
+
console.log("\nConfig comparison (installed providers: Claude " + (hasClaude ? "yes" : "no") + ", Codex " + (hasCodex ? "yes" : "no") + ")\n");
|
|
55
|
+
console.log(" Current: planner " + formatRole(current, "planner"));
|
|
56
|
+
console.log(" implementer " + formatRole(current, "implementer"));
|
|
57
|
+
console.log("");
|
|
58
|
+
console.log(" Suggested: planner " + formatRole(suggested, "planner"));
|
|
59
|
+
console.log(" implementer " + formatRole(suggested, "implementer"));
|
|
60
|
+
console.log("");
|
|
61
|
+
if (apply) {
|
|
62
|
+
await fs.writeJson(configPath, suggested, { spaces: 2 });
|
|
63
|
+
console.log("Updated planforge.json to suggested config.");
|
|
64
|
+
}
|
|
65
|
+
else {
|
|
66
|
+
console.log("Run planforge config suggest --apply to update planforge.json.");
|
|
67
|
+
}
|
|
68
|
+
}
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* planforge doctor - check environment and providers
|
|
3
|
+
*/
|
|
4
|
+
import fs from "fs-extra";
|
|
5
|
+
import { resolve } from "path";
|
|
6
|
+
import { getProjectRoot, getPlansDir } from "../utils/paths.js";
|
|
7
|
+
import { checkClaude } from "../providers/claude.js";
|
|
8
|
+
import { checkCodex } from "../providers/codex.js";
|
|
9
|
+
function statusSymbol(s) {
|
|
10
|
+
switch (s) {
|
|
11
|
+
case "ok":
|
|
12
|
+
return "[OK]";
|
|
13
|
+
case "warn":
|
|
14
|
+
return "[WARN]";
|
|
15
|
+
case "error":
|
|
16
|
+
return "[ERROR]";
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
export async function runDoctor(_args) {
|
|
20
|
+
const cwd = process.cwd();
|
|
21
|
+
const projectRoot = getProjectRoot(cwd);
|
|
22
|
+
const plansDir = getPlansDir(projectRoot);
|
|
23
|
+
const checks = [];
|
|
24
|
+
const hasClaude = checkClaude();
|
|
25
|
+
const hasCodex = checkCodex();
|
|
26
|
+
checks.push({
|
|
27
|
+
name: "Claude CLI",
|
|
28
|
+
status: hasClaude ? "ok" : "warn",
|
|
29
|
+
message: hasClaude ? "available" : "not found (planning /p will be limited)",
|
|
30
|
+
});
|
|
31
|
+
checks.push({
|
|
32
|
+
name: "Codex CLI",
|
|
33
|
+
status: hasCodex ? "ok" : "warn",
|
|
34
|
+
message: hasCodex ? "available" : "not found (implementation /i will be limited)",
|
|
35
|
+
});
|
|
36
|
+
const claudeMdPath = resolve(projectRoot, "CLAUDE.md");
|
|
37
|
+
const hasClaudeMd = await fs.pathExists(claudeMdPath);
|
|
38
|
+
checks.push({
|
|
39
|
+
name: "CLAUDE.md",
|
|
40
|
+
status: hasClaudeMd ? "ok" : (hasClaude ? "warn" : "ok"),
|
|
41
|
+
message: hasClaudeMd ? "exists" : (hasClaude ? "missing (run claude /init)" : "n/a"),
|
|
42
|
+
});
|
|
43
|
+
const agentsPath = resolve(projectRoot, "AGENTS.md");
|
|
44
|
+
const hasAgentsMd = await fs.pathExists(agentsPath);
|
|
45
|
+
checks.push({
|
|
46
|
+
name: "AGENTS.md",
|
|
47
|
+
status: hasAgentsMd ? "ok" : (hasCodex ? "warn" : "ok"),
|
|
48
|
+
message: hasAgentsMd ? "exists" : (hasCodex ? "missing" : "n/a"),
|
|
49
|
+
});
|
|
50
|
+
const configPath = resolve(projectRoot, "planforge.json");
|
|
51
|
+
const hasConfig = await fs.pathExists(configPath);
|
|
52
|
+
checks.push({
|
|
53
|
+
name: "planforge.json",
|
|
54
|
+
status: hasConfig ? "ok" : "error",
|
|
55
|
+
message: hasConfig ? "exists" : "missing (run planforge init)",
|
|
56
|
+
});
|
|
57
|
+
const hasPlansDir = await fs.pathExists(plansDir);
|
|
58
|
+
checks.push({
|
|
59
|
+
name: ".cursor/plans",
|
|
60
|
+
status: hasPlansDir ? "ok" : "error",
|
|
61
|
+
message: hasPlansDir ? "exists" : "missing (run planforge init)",
|
|
62
|
+
});
|
|
63
|
+
console.log("\nPlanForge doctor");
|
|
64
|
+
console.log(" ------------------------------");
|
|
65
|
+
console.log("");
|
|
66
|
+
const maxName = Math.max(...checks.map((c) => c.name.length), 16);
|
|
67
|
+
for (const c of checks) {
|
|
68
|
+
const sym = statusSymbol(c.status);
|
|
69
|
+
const padded = c.name.padEnd(maxName);
|
|
70
|
+
console.log(` ${sym} ${padded} ${c.message}`);
|
|
71
|
+
}
|
|
72
|
+
console.log("");
|
|
73
|
+
if (!hasClaude || !hasCodex) {
|
|
74
|
+
console.log(" Run planforge init to install missing providers.");
|
|
75
|
+
}
|
|
76
|
+
if (hasClaude || hasCodex) {
|
|
77
|
+
console.log(" Run planforge config suggest to see recommended config for your providers.");
|
|
78
|
+
}
|
|
79
|
+
console.log("");
|
|
80
|
+
const hasError = checks.some((c) => c.status === "error");
|
|
81
|
+
if (hasError) {
|
|
82
|
+
process.exit(1);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* planforge implement <prompt> - run implementation via configured implementer provider
|
|
3
|
+
*/
|
|
4
|
+
import fs from "fs-extra";
|
|
5
|
+
import { spawnSync } from "child_process";
|
|
6
|
+
import { resolve, dirname } from "path";
|
|
7
|
+
import { readFile } from "fs/promises";
|
|
8
|
+
import { getProjectRoot } from "../utils/paths.js";
|
|
9
|
+
import { getActivePlanPath } from "../utils/active-plan.js";
|
|
10
|
+
import { parseFilesFromPlan } from "../utils/plan-files.js";
|
|
11
|
+
import { getProjectContext } from "../utils/project-context.js";
|
|
12
|
+
import { loadConfig } from "../config/load.js";
|
|
13
|
+
import { getImplementerRunner } from "../providers/registry.js";
|
|
14
|
+
const MAX_CODE_CONTEXT_CHARS = 12000;
|
|
15
|
+
const MAX_RECENT_COMMITS_PER_FILE_CHARS = 400;
|
|
16
|
+
const MAX_RECENT_COMMITS_PER_FILE_COUNT = 5;
|
|
17
|
+
/** Extract (relative path, content) from implement output: lines like "### 1) `path/to/file`" followed by a fenced code block. */
|
|
18
|
+
function extractFilesFromOutput(text) {
|
|
19
|
+
const files = [];
|
|
20
|
+
// Match: optional "### N) " then `path` then optional whitespace/newlines then ```lang? newline content ```
|
|
21
|
+
const blockRe = /(?:###\s*\d+\)\s*)?`([^`]+)`\s*[\r\n]+\s*```[\w]*\r?\n([\s\S]*?)```/g;
|
|
22
|
+
let m;
|
|
23
|
+
while ((m = blockRe.exec(text)) !== null) {
|
|
24
|
+
const rawPath = m[1].trim().replace(/\\/g, "/").replace(/^\//, "");
|
|
25
|
+
if (!rawPath || rawPath.includes(".."))
|
|
26
|
+
continue;
|
|
27
|
+
const content = m[2].replace(/\r\n/g, "\n").trimEnd();
|
|
28
|
+
files.push({ path: rawPath, content });
|
|
29
|
+
}
|
|
30
|
+
return files;
|
|
31
|
+
}
|
|
32
|
+
async function resolveContext(opts, cwd) {
|
|
33
|
+
if (!opts)
|
|
34
|
+
return undefined;
|
|
35
|
+
const parts = [];
|
|
36
|
+
if (opts.contextFile) {
|
|
37
|
+
const absPath = resolve(cwd, opts.contextFile);
|
|
38
|
+
try {
|
|
39
|
+
const content = await readFile(absPath, "utf-8");
|
|
40
|
+
if (content.trim())
|
|
41
|
+
parts.push(content.trim());
|
|
42
|
+
}
|
|
43
|
+
catch (err) {
|
|
44
|
+
console.error("Failed to read context file:", err.message);
|
|
45
|
+
process.exit(1);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
if (opts.context?.trim())
|
|
49
|
+
parts.push(opts.context.trim());
|
|
50
|
+
if (parts.length === 0)
|
|
51
|
+
return undefined;
|
|
52
|
+
return parts.join("\n\n");
|
|
53
|
+
}
|
|
54
|
+
/** True if path looks like a glob (contains * or **). */
|
|
55
|
+
function isGlob(path) {
|
|
56
|
+
return path.includes("*");
|
|
57
|
+
}
|
|
58
|
+
/** Build codeContext string from filesToChange (non-glob paths only), capped in total size. */
|
|
59
|
+
async function buildCodeContext(projectRoot, filesToChange) {
|
|
60
|
+
const root = resolve(projectRoot);
|
|
61
|
+
const parts = [];
|
|
62
|
+
let total = 0;
|
|
63
|
+
for (const rel of filesToChange) {
|
|
64
|
+
if (isGlob(rel) || total >= MAX_CODE_CONTEXT_CHARS)
|
|
65
|
+
continue;
|
|
66
|
+
const abs = resolve(root, rel);
|
|
67
|
+
if (!abs.startsWith(root))
|
|
68
|
+
continue;
|
|
69
|
+
try {
|
|
70
|
+
const content = await readFile(abs, "utf-8");
|
|
71
|
+
const block = `### \`${rel}\`\n\`\`\`\n${content}\n\`\`\`\n`;
|
|
72
|
+
if (total + block.length > MAX_CODE_CONTEXT_CHARS) {
|
|
73
|
+
const remaining = MAX_CODE_CONTEXT_CHARS - total - 50;
|
|
74
|
+
if (remaining > 0) {
|
|
75
|
+
parts.push(`### \`${rel}\`\n\`\`\`\n${content.slice(0, remaining)}\n...(truncated)\n\`\`\`\n`);
|
|
76
|
+
}
|
|
77
|
+
break;
|
|
78
|
+
}
|
|
79
|
+
parts.push(block);
|
|
80
|
+
total += block.length;
|
|
81
|
+
}
|
|
82
|
+
catch {
|
|
83
|
+
/* skip unreadable or binary */
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
if (parts.length === 0)
|
|
87
|
+
return undefined;
|
|
88
|
+
return parts.join("\n");
|
|
89
|
+
}
|
|
90
|
+
/** Run git log --oneline -n 1 for a path. Returns one-line message or null. */
|
|
91
|
+
function runGitLogOneline(projectRoot, relPath) {
|
|
92
|
+
try {
|
|
93
|
+
const result = spawnSync("git", ["log", "--oneline", "-n", "1", "--", relPath], {
|
|
94
|
+
cwd: projectRoot,
|
|
95
|
+
encoding: "utf-8",
|
|
96
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
97
|
+
});
|
|
98
|
+
if (result.status !== 0)
|
|
99
|
+
return null;
|
|
100
|
+
return (result.stdout ?? "").trim();
|
|
101
|
+
}
|
|
102
|
+
catch {
|
|
103
|
+
return null;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
/** Build "Recent commit (per file)" string from filesToChange (non-glob, up to 5 files), capped at 400 chars. */
|
|
107
|
+
function buildRecentCommitsForFiles(projectRoot, filesToChange) {
|
|
108
|
+
const root = resolve(projectRoot);
|
|
109
|
+
const lines = [];
|
|
110
|
+
let count = 0;
|
|
111
|
+
for (const rel of filesToChange) {
|
|
112
|
+
if (count >= MAX_RECENT_COMMITS_PER_FILE_COUNT || isGlob(rel))
|
|
113
|
+
continue;
|
|
114
|
+
const abs = resolve(root, rel);
|
|
115
|
+
if (!abs.startsWith(root))
|
|
116
|
+
continue;
|
|
117
|
+
const msg = runGitLogOneline(projectRoot, rel);
|
|
118
|
+
if (msg) {
|
|
119
|
+
lines.push(`${rel}: ${msg}`);
|
|
120
|
+
count++;
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
if (lines.length === 0)
|
|
124
|
+
return undefined;
|
|
125
|
+
let out = lines.join("\n");
|
|
126
|
+
if (out.length > MAX_RECENT_COMMITS_PER_FILE_CHARS) {
|
|
127
|
+
out = out.slice(0, MAX_RECENT_COMMITS_PER_FILE_CHARS) + "\n...(truncated)";
|
|
128
|
+
}
|
|
129
|
+
return out;
|
|
130
|
+
}
|
|
131
|
+
export async function runImplement(args, opts) {
|
|
132
|
+
const prompt = args.join(" ").trim();
|
|
133
|
+
if (!prompt) {
|
|
134
|
+
console.error("Usage: planforge implement <prompt>");
|
|
135
|
+
process.exit(1);
|
|
136
|
+
}
|
|
137
|
+
const cwd = process.cwd();
|
|
138
|
+
const projectRoot = getProjectRoot(cwd);
|
|
139
|
+
const context = await resolveContext(opts, cwd);
|
|
140
|
+
let planContent;
|
|
141
|
+
if (opts?.planFile) {
|
|
142
|
+
const absPlan = resolve(cwd, opts.planFile);
|
|
143
|
+
try {
|
|
144
|
+
planContent = await readFile(absPlan, "utf-8");
|
|
145
|
+
}
|
|
146
|
+
catch (err) {
|
|
147
|
+
console.error("Failed to read plan file:", err.message);
|
|
148
|
+
process.exit(1);
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
else {
|
|
152
|
+
const activePath = getActivePlanPath(projectRoot);
|
|
153
|
+
if (activePath) {
|
|
154
|
+
try {
|
|
155
|
+
planContent = await readFile(activePath, "utf-8");
|
|
156
|
+
}
|
|
157
|
+
catch {
|
|
158
|
+
/* skip plan if unreadable */
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
const config = await loadConfig(projectRoot);
|
|
163
|
+
const runner = getImplementerRunner(config.implementer.provider);
|
|
164
|
+
if (!runner) {
|
|
165
|
+
console.error(`Unknown implementer provider: ${config.implementer.provider}. Check planforge.json.`);
|
|
166
|
+
process.exit(1);
|
|
167
|
+
}
|
|
168
|
+
if (!runner.check()) {
|
|
169
|
+
console.error(`${config.implementer.provider} CLI not found. Install the provider CLI to use planforge implement.`);
|
|
170
|
+
process.exit(1);
|
|
171
|
+
}
|
|
172
|
+
const filesToChange = opts?.files?.length ? opts.files : parseFilesFromPlan(planContent);
|
|
173
|
+
const codeContext = filesToChange.length > 0 ? await buildCodeContext(projectRoot, filesToChange) : undefined;
|
|
174
|
+
const projectContext = getProjectContext(projectRoot);
|
|
175
|
+
const recentCommitsPerFile = filesToChange.length > 0 ? buildRecentCommitsForFiles(projectRoot, filesToChange) : undefined;
|
|
176
|
+
try {
|
|
177
|
+
const result = await runner.runImplement(prompt, {
|
|
178
|
+
cwd: projectRoot,
|
|
179
|
+
context,
|
|
180
|
+
planContent,
|
|
181
|
+
filesToChange: filesToChange.length > 0 ? filesToChange : undefined,
|
|
182
|
+
codeContext,
|
|
183
|
+
projectContext,
|
|
184
|
+
recentCommitsPerFile,
|
|
185
|
+
});
|
|
186
|
+
const extracted = extractFilesFromOutput(result);
|
|
187
|
+
const root = resolve(projectRoot);
|
|
188
|
+
if (extracted.length > 0) {
|
|
189
|
+
for (const { path: relPath, content } of extracted) {
|
|
190
|
+
const absPath = resolve(root, relPath);
|
|
191
|
+
if (!absPath.startsWith(root))
|
|
192
|
+
continue;
|
|
193
|
+
await fs.ensureDir(dirname(absPath));
|
|
194
|
+
await fs.writeFile(absPath, content, "utf-8");
|
|
195
|
+
console.log("Written:", relPath);
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
// Result was already streamed to terminal by the provider
|
|
199
|
+
}
|
|
200
|
+
catch (err) {
|
|
201
|
+
console.error("Implement failed:", err.message);
|
|
202
|
+
process.exit(1);
|
|
203
|
+
}
|
|
204
|
+
}
|
|
@@ -0,0 +1,267 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* planforge init - detect providers, install slash commands, create .cursor/plans
|
|
3
|
+
*/
|
|
4
|
+
import * as readline from "readline";
|
|
5
|
+
import { spawnSync } from "child_process";
|
|
6
|
+
import fs from "fs-extra";
|
|
7
|
+
import { resolve } from "path";
|
|
8
|
+
import { getProjectRoot, getPlansDir } from "../utils/paths.js";
|
|
9
|
+
import { checkClaude, CLIENT_NPM_PACKAGE as CLAUDE_PKG } from "../providers/claude.js";
|
|
10
|
+
import { checkCodex, CLIENT_NPM_PACKAGE as CODEX_PKG } from "../providers/codex.js";
|
|
11
|
+
import { runCommand, runCommandLive } from "../utils/shell.js";
|
|
12
|
+
import { installTemplates } from "../templates/install.js";
|
|
13
|
+
import { getPresetForProviders } from "../config/presets.js";
|
|
14
|
+
import { formatRole, configEqual } from "./config.js";
|
|
15
|
+
const DEFAULT_AGENTS_MD = `# AGENTS.md
|
|
16
|
+
|
|
17
|
+
Codex/OpenAI agent context for this project.
|
|
18
|
+
Customize this file to give the implementer (/i) relevant project context.
|
|
19
|
+
`;
|
|
20
|
+
const DEFAULT_CLAUDE_MD = `# CLAUDE.md
|
|
21
|
+
|
|
22
|
+
Claude project context. Run 'claude /init' after signing in, or edit this file.
|
|
23
|
+
`;
|
|
24
|
+
function ask(question, defaultVal) {
|
|
25
|
+
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
|
26
|
+
const def = defaultVal ? ` (default: ${defaultVal})` : "";
|
|
27
|
+
return new Promise((resolvePromise) => {
|
|
28
|
+
rl.question(question + def + ": ", (answer) => {
|
|
29
|
+
rl.close();
|
|
30
|
+
resolvePromise((answer.trim() || defaultVal));
|
|
31
|
+
});
|
|
32
|
+
});
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* First prompt: install one missing provider or no. Returns "no" when non-TTY.
|
|
36
|
+
*/
|
|
37
|
+
async function promptFirstProvider(hasClaude, hasCodex) {
|
|
38
|
+
if (!process.stdin.isTTY) {
|
|
39
|
+
return "no";
|
|
40
|
+
}
|
|
41
|
+
console.log("\nPlanForge init - provider check\n");
|
|
42
|
+
console.log(` Claude CLI ${hasClaude ? "installed" : "not found"} (recommended for /p planning)`);
|
|
43
|
+
console.log(` Codex CLI ${hasCodex ? "installed" : "not found"} (recommended for /i implementation)`);
|
|
44
|
+
console.log("");
|
|
45
|
+
if (hasClaude && !hasCodex) {
|
|
46
|
+
console.log("Install Codex?");
|
|
47
|
+
console.log(" (1) Yes (2) No");
|
|
48
|
+
const raw = await ask("Choice", "2");
|
|
49
|
+
const n = raw === "" ? 2 : parseInt(raw, 10);
|
|
50
|
+
return n === 1 ? "codex" : "no";
|
|
51
|
+
}
|
|
52
|
+
if (!hasClaude && hasCodex) {
|
|
53
|
+
console.log("Install Claude?");
|
|
54
|
+
console.log(" (1) Yes (2) No");
|
|
55
|
+
const raw = await ask("Choice", "2");
|
|
56
|
+
const n = raw === "" ? 2 : parseInt(raw, 10);
|
|
57
|
+
return n === 1 ? "claude" : "no";
|
|
58
|
+
}
|
|
59
|
+
if (!hasClaude && !hasCodex) {
|
|
60
|
+
console.log("Which one to install first?");
|
|
61
|
+
console.log(` 1) Claude (install ${CLAUDE_PKG})`);
|
|
62
|
+
console.log(` 2) Codex (install ${CODEX_PKG})`);
|
|
63
|
+
console.log("");
|
|
64
|
+
const raw = await ask("Choice [1-2]", "1");
|
|
65
|
+
const n = raw === "" ? 1 : parseInt(raw, 10);
|
|
66
|
+
return n === 2 ? "codex" : "claude";
|
|
67
|
+
}
|
|
68
|
+
return "no";
|
|
69
|
+
}
|
|
70
|
+
function installProviderPackage(pkg) {
|
|
71
|
+
const ok = runCommandLive("npm", ["install", "-g", pkg]);
|
|
72
|
+
if (!ok) {
|
|
73
|
+
console.warn("Warning: npm install -g", pkg, "failed.");
|
|
74
|
+
}
|
|
75
|
+
return ok;
|
|
76
|
+
}
|
|
77
|
+
function formatRoleModel(config, role) {
|
|
78
|
+
const r = config[role];
|
|
79
|
+
let s = `${r.provider} / ${r.model}`;
|
|
80
|
+
if (r.reasoning)
|
|
81
|
+
s += ` (reasoning: ${r.reasoning})`;
|
|
82
|
+
if (r.effort)
|
|
83
|
+
s += ` (effort: ${r.effort})`;
|
|
84
|
+
return s;
|
|
85
|
+
}
|
|
86
|
+
/** Draw Complete UI box with title and /p, /i model lines. Uses preset for current providers so the box reflects recommended config. */
|
|
87
|
+
function showCompleteBox(hasClaude, hasCodex, title) {
|
|
88
|
+
const config = getPresetForProviders(hasClaude, hasCodex);
|
|
89
|
+
const pLine = ` /p (planning) : ${formatRoleModel(config, "planner")}`;
|
|
90
|
+
const iLine = ` /i (implementation): ${formatRoleModel(config, "implementer")}`;
|
|
91
|
+
const lines = [title, pLine, iLine];
|
|
92
|
+
const width = Math.max(...lines.map((s) => s.length), 40);
|
|
93
|
+
const top = "+" + "-".repeat(width + 2) + "+";
|
|
94
|
+
const bottom = "+" + "-".repeat(width + 2) + "+";
|
|
95
|
+
console.log("");
|
|
96
|
+
console.log(top);
|
|
97
|
+
for (const line of lines) {
|
|
98
|
+
console.log("| " + line.padEnd(width) + " |");
|
|
99
|
+
}
|
|
100
|
+
console.log(bottom);
|
|
101
|
+
console.log("");
|
|
102
|
+
}
|
|
103
|
+
/** After box: install the other provider or finish. Returns true if we should set finishedWithCodexOnly and show guide. */
|
|
104
|
+
async function promptInstallOtherAfterBox(hasClaude, hasCodex) {
|
|
105
|
+
if (!process.stdin.isTTY || (hasClaude && hasCodex)) {
|
|
106
|
+
return "finish";
|
|
107
|
+
}
|
|
108
|
+
const other = !hasClaude ? "Claude" : "Codex";
|
|
109
|
+
console.log(`Install ${other} too?`);
|
|
110
|
+
console.log(" (1) Yes (2) No, finish");
|
|
111
|
+
console.log("");
|
|
112
|
+
const raw = await ask("Choice [1-2]", "2");
|
|
113
|
+
const n = raw === "" ? 2 : parseInt(raw, 10);
|
|
114
|
+
return n === 1 ? "install_other" : "finish";
|
|
115
|
+
}
|
|
116
|
+
export async function runInit(args) {
|
|
117
|
+
const skipProviderInstall = args.includes("--skip-provider-install");
|
|
118
|
+
const cwd = process.cwd();
|
|
119
|
+
const projectRoot = getProjectRoot(cwd);
|
|
120
|
+
try {
|
|
121
|
+
let hasClaude = checkClaude();
|
|
122
|
+
let hasCodex = checkCodex();
|
|
123
|
+
let justInstalledClaude = false;
|
|
124
|
+
let installedClaudeThisRun = false;
|
|
125
|
+
let installedCodexThisRun = false;
|
|
126
|
+
let finishedWithCodexOnly = false;
|
|
127
|
+
let showGuideAtEnd = false;
|
|
128
|
+
if (!skipProviderInstall && (!hasClaude || !hasCodex)) {
|
|
129
|
+
const first = await promptFirstProvider(hasClaude, hasCodex);
|
|
130
|
+
if (first !== "no") {
|
|
131
|
+
if (first === "claude" && !hasClaude) {
|
|
132
|
+
justInstalledClaude = installProviderPackage(CLAUDE_PKG);
|
|
133
|
+
hasClaude = checkClaude();
|
|
134
|
+
installedClaudeThisRun = true;
|
|
135
|
+
}
|
|
136
|
+
else if (first === "codex" && !hasCodex) {
|
|
137
|
+
installProviderPackage(CODEX_PKG);
|
|
138
|
+
hasCodex = checkCodex();
|
|
139
|
+
installedCodexThisRun = true;
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
if (hasCodex && installedCodexThisRun && process.stdin.isTTY) {
|
|
144
|
+
console.log("\nCodex CLI was just installed. You'll be switched to Codex to sign in. Exit with Ctrl+C when done.\n");
|
|
145
|
+
spawnSync("codex", [], { stdio: "inherit", cwd: projectRoot, shell: true });
|
|
146
|
+
}
|
|
147
|
+
if (hasClaude && justInstalledClaude && process.stdin.isTTY) {
|
|
148
|
+
console.log("\nClaude CLI was just installed. You'll be switched to Claude to sign in or complete setup. Exit Claude when done.\n");
|
|
149
|
+
spawnSync("claude", [], { stdio: "inherit", cwd: projectRoot, shell: true });
|
|
150
|
+
}
|
|
151
|
+
if (installedCodexThisRun || installedClaudeThisRun) {
|
|
152
|
+
const boxTitle = hasClaude && hasCodex
|
|
153
|
+
? "Both providers are ready."
|
|
154
|
+
: hasCodex
|
|
155
|
+
? "Complete. Codex is ready."
|
|
156
|
+
: "Complete. Claude is ready.";
|
|
157
|
+
showCompleteBox(hasClaude, hasCodex, boxTitle);
|
|
158
|
+
const nextAfterBox = await promptInstallOtherAfterBox(hasClaude, hasCodex);
|
|
159
|
+
if (nextAfterBox === "finish") {
|
|
160
|
+
if (hasCodex && !hasClaude) {
|
|
161
|
+
finishedWithCodexOnly = true;
|
|
162
|
+
}
|
|
163
|
+
if (!hasClaude || !hasCodex) {
|
|
164
|
+
showGuideAtEnd = true;
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
else {
|
|
168
|
+
if (!hasClaude) {
|
|
169
|
+
justInstalledClaude = installProviderPackage(CLAUDE_PKG);
|
|
170
|
+
hasClaude = checkClaude();
|
|
171
|
+
installedClaudeThisRun = true;
|
|
172
|
+
if (hasClaude && process.stdin.isTTY) {
|
|
173
|
+
console.log("\nSwitching to Claude to sign in. Exit when done.\n");
|
|
174
|
+
spawnSync("claude", [], { stdio: "inherit", cwd: projectRoot, shell: true });
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
else if (!hasCodex) {
|
|
178
|
+
installProviderPackage(CODEX_PKG);
|
|
179
|
+
hasCodex = checkCodex();
|
|
180
|
+
installedCodexThisRun = true;
|
|
181
|
+
if (hasCodex && process.stdin.isTTY) {
|
|
182
|
+
console.log("\nSwitching to Codex to sign in. Exit with Ctrl+C when done.\n");
|
|
183
|
+
spawnSync("codex", [], { stdio: "inherit", cwd: projectRoot, shell: true });
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
if (hasClaude && hasCodex) {
|
|
187
|
+
showCompleteBox(hasClaude, hasCodex, "Both providers are ready.");
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
if (hasClaude && !finishedWithCodexOnly) {
|
|
192
|
+
try {
|
|
193
|
+
runCommand("claude", ["/init"], projectRoot);
|
|
194
|
+
}
|
|
195
|
+
catch (err) {
|
|
196
|
+
console.warn("Warning: claude /init failed:", err.message);
|
|
197
|
+
const claudeMdPath = resolve(projectRoot, "CLAUDE.md");
|
|
198
|
+
if (!(await fs.pathExists(claudeMdPath))) {
|
|
199
|
+
await fs.writeFile(claudeMdPath, DEFAULT_CLAUDE_MD, "utf-8");
|
|
200
|
+
console.log(" Created CLAUDE.md");
|
|
201
|
+
}
|
|
202
|
+
console.log(" Claude /init failed (sign in may be required). Run 'claude' to sign in, then run 'claude /init' in this project.");
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
if (hasCodex) {
|
|
206
|
+
const agentsPath = resolve(projectRoot, "AGENTS.md");
|
|
207
|
+
if (!(await fs.pathExists(agentsPath))) {
|
|
208
|
+
await fs.writeFile(agentsPath, DEFAULT_AGENTS_MD, "utf-8");
|
|
209
|
+
console.log(" Created AGENTS.md");
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
await installTemplates(projectRoot);
|
|
213
|
+
const plansDir = getPlansDir(projectRoot);
|
|
214
|
+
await fs.ensureDir(plansDir);
|
|
215
|
+
const configPath = resolve(projectRoot, "planforge.json");
|
|
216
|
+
const configExists = await fs.pathExists(configPath);
|
|
217
|
+
let createdConfig = false;
|
|
218
|
+
let updatedConfig = false;
|
|
219
|
+
if (!configExists) {
|
|
220
|
+
const preset = getPresetForProviders(hasClaude, hasCodex);
|
|
221
|
+
await fs.writeJson(configPath, preset, { spaces: 2 });
|
|
222
|
+
createdConfig = true;
|
|
223
|
+
}
|
|
224
|
+
else {
|
|
225
|
+
const current = (await fs.readJson(configPath));
|
|
226
|
+
const suggested = getPresetForProviders(hasClaude, hasCodex);
|
|
227
|
+
if (!configEqual(current, suggested) && process.stdin.isTTY) {
|
|
228
|
+
console.log("");
|
|
229
|
+
console.log(" planforge.json already exists. Current config differs from suggested for your installed providers.");
|
|
230
|
+
console.log("");
|
|
231
|
+
console.log(" Current: planner " + formatRole(current, "planner"));
|
|
232
|
+
console.log(" implementer " + formatRole(current, "implementer"));
|
|
233
|
+
console.log("");
|
|
234
|
+
console.log(" Suggested: planner " + formatRole(suggested, "planner"));
|
|
235
|
+
console.log(" implementer " + formatRole(suggested, "implementer"));
|
|
236
|
+
console.log("");
|
|
237
|
+
const raw = await ask("Update planforge.json to suggested? (1) Yes (2) No, keep current", "2");
|
|
238
|
+
const n = raw === "" ? 2 : parseInt(raw, 10);
|
|
239
|
+
if (n === 1) {
|
|
240
|
+
await fs.writeJson(configPath, suggested, { spaces: 2 });
|
|
241
|
+
updatedConfig = true;
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
console.log("");
|
|
246
|
+
if (showGuideAtEnd) {
|
|
247
|
+
console.log(" Congratulations! PlanForge is ready.");
|
|
248
|
+
console.log("");
|
|
249
|
+
console.log(" Example: Design a simple tetris game.");
|
|
250
|
+
console.log(" In Cursor, use /p for planning and /i for implementation. Try it out!");
|
|
251
|
+
console.log("");
|
|
252
|
+
}
|
|
253
|
+
console.log(" Created .cursor/plans");
|
|
254
|
+
if (createdConfig) {
|
|
255
|
+
console.log(" Created planforge.json");
|
|
256
|
+
}
|
|
257
|
+
else if (updatedConfig) {
|
|
258
|
+
console.log(" Updated planforge.json to suggested config.");
|
|
259
|
+
}
|
|
260
|
+
console.log("");
|
|
261
|
+
console.log("PlanForge init complete.");
|
|
262
|
+
}
|
|
263
|
+
catch (err) {
|
|
264
|
+
console.error("PlanForge init failed:", err.message);
|
|
265
|
+
process.exit(1);
|
|
266
|
+
}
|
|
267
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* planforge install - install Cursor slash commands and templates
|
|
3
|
+
*/
|
|
4
|
+
import { getProjectRoot } from "../utils/paths.js";
|
|
5
|
+
import { installTemplates } from "../templates/install.js";
|
|
6
|
+
export async function runInstall(args) {
|
|
7
|
+
const force = args.includes("--force");
|
|
8
|
+
const cwd = process.cwd();
|
|
9
|
+
const projectRoot = getProjectRoot(cwd);
|
|
10
|
+
try {
|
|
11
|
+
await installTemplates(projectRoot, { force });
|
|
12
|
+
console.log("PlanForge templates installed to .cursor/skills and .cursor/rules.");
|
|
13
|
+
}
|
|
14
|
+
catch (err) {
|
|
15
|
+
console.error("Install failed:", err.message);
|
|
16
|
+
process.exit(1);
|
|
17
|
+
}
|
|
18
|
+
}
|