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,42 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Provider registry: route plan/implement by provider name from planforge.json.
|
|
3
|
+
* Add new providers here and implement check + runPlan/runImplement in their module.
|
|
4
|
+
*/
|
|
5
|
+
import * as claude from "./claude.js";
|
|
6
|
+
import * as codex from "./codex.js";
|
|
7
|
+
const claudePlanner = {
|
|
8
|
+
check: () => claude.checkClaude(),
|
|
9
|
+
runPlan: (goal, opts) => claude.runPlan(goal, opts),
|
|
10
|
+
};
|
|
11
|
+
const codexPlanner = {
|
|
12
|
+
check: () => codex.checkCodex(),
|
|
13
|
+
runPlan: (goal, opts) => codex.runPlan(goal, opts),
|
|
14
|
+
};
|
|
15
|
+
const claudeImplementer = {
|
|
16
|
+
check: () => claude.checkClaude(),
|
|
17
|
+
runImplement: (prompt, opts) => claude.runImplement(prompt, opts),
|
|
18
|
+
};
|
|
19
|
+
const codexImplementer = {
|
|
20
|
+
check: () => codex.checkCodex(),
|
|
21
|
+
runImplement: (prompt, opts) => codex.runImplement(prompt, opts),
|
|
22
|
+
};
|
|
23
|
+
export function getPlannerRunner(provider) {
|
|
24
|
+
switch (provider) {
|
|
25
|
+
case "claude":
|
|
26
|
+
return claudePlanner;
|
|
27
|
+
case "codex":
|
|
28
|
+
return codexPlanner;
|
|
29
|
+
default:
|
|
30
|
+
return null;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
export function getImplementerRunner(provider) {
|
|
34
|
+
switch (provider) {
|
|
35
|
+
case "claude":
|
|
36
|
+
return claudeImplementer;
|
|
37
|
+
case "codex":
|
|
38
|
+
return codexImplementer;
|
|
39
|
+
default:
|
|
40
|
+
return null;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Copy PlanForge templates (skills, rules, config) into project
|
|
3
|
+
*/
|
|
4
|
+
import fs from "fs-extra";
|
|
5
|
+
import { resolve } from "path";
|
|
6
|
+
import { getTemplatesRoot } from "../utils/paths.js";
|
|
7
|
+
export async function installTemplates(projectRoot, options = {}) {
|
|
8
|
+
const templatesRoot = getTemplatesRoot();
|
|
9
|
+
const cursorDir = resolve(projectRoot, ".cursor");
|
|
10
|
+
const cursorTemplates = resolve(templatesRoot, "cursor");
|
|
11
|
+
await fs.ensureDir(cursorDir);
|
|
12
|
+
const skillsSrc = resolve(cursorTemplates, "skills");
|
|
13
|
+
const skillsDest = resolve(cursorDir, "skills");
|
|
14
|
+
await fs.ensureDir(skillsDest);
|
|
15
|
+
if (await fs.pathExists(resolve(skillsSrc, "p"))) {
|
|
16
|
+
await fs.copy(resolve(skillsSrc, "p"), resolve(skillsDest, "p"), { overwrite: true });
|
|
17
|
+
}
|
|
18
|
+
if (await fs.pathExists(resolve(skillsSrc, "i"))) {
|
|
19
|
+
await fs.copy(resolve(skillsSrc, "i"), resolve(skillsDest, "i"), { overwrite: true });
|
|
20
|
+
}
|
|
21
|
+
const rulesSrc = resolve(cursorTemplates, "rules");
|
|
22
|
+
const rulesDest = resolve(cursorDir, "rules");
|
|
23
|
+
if (await fs.pathExists(rulesSrc)) {
|
|
24
|
+
await fs.ensureDir(rulesDest);
|
|
25
|
+
await fs.copy(rulesSrc, rulesDest, { overwrite: true });
|
|
26
|
+
}
|
|
27
|
+
const configSrc = resolve(templatesRoot, "config", "planforge.json");
|
|
28
|
+
const configDest = resolve(projectRoot, "planforge.json");
|
|
29
|
+
if (await fs.pathExists(configSrc)) {
|
|
30
|
+
if (options.force || !(await fs.pathExists(configDest))) {
|
|
31
|
+
await fs.copy(configSrc, configDest, { overwrite: true });
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Resolve which plan file to use for implement: index.json activePlan or latest .plan.md by mtime.
|
|
3
|
+
*/
|
|
4
|
+
import { existsSync, statSync, readFileSync, readdirSync } from "fs";
|
|
5
|
+
import { resolve, join } from "path";
|
|
6
|
+
import { getPlansDir } from "./paths.js";
|
|
7
|
+
const INDEX_JSON = "index.json";
|
|
8
|
+
export function getActivePlanPath(projectRoot) {
|
|
9
|
+
const plansDir = getPlansDir(projectRoot);
|
|
10
|
+
if (!existsSync(plansDir))
|
|
11
|
+
return null;
|
|
12
|
+
const indexPath = join(plansDir, INDEX_JSON);
|
|
13
|
+
if (existsSync(indexPath)) {
|
|
14
|
+
try {
|
|
15
|
+
const raw = readFileSync(indexPath, "utf-8");
|
|
16
|
+
const data = JSON.parse(raw);
|
|
17
|
+
const name = data.activePlan?.trim();
|
|
18
|
+
if (name) {
|
|
19
|
+
const candidate = resolve(plansDir, name);
|
|
20
|
+
if (existsSync(candidate))
|
|
21
|
+
return candidate;
|
|
22
|
+
const withExt = name.endsWith(".plan.md") ? name : `${name}.plan.md`;
|
|
23
|
+
const candidate2 = resolve(plansDir, withExt);
|
|
24
|
+
if (existsSync(candidate2))
|
|
25
|
+
return candidate2;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
catch {
|
|
29
|
+
/* ignore invalid index.json */
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
let latestPath = null;
|
|
33
|
+
let latestMtime = 0;
|
|
34
|
+
try {
|
|
35
|
+
const entries = readdirSync(plansDir, { withFileTypes: true });
|
|
36
|
+
for (const e of entries) {
|
|
37
|
+
if (!e.isFile() || !e.name.endsWith(".plan.md"))
|
|
38
|
+
continue;
|
|
39
|
+
const full = join(plansDir, e.name);
|
|
40
|
+
const mtime = statSync(full).mtimeMs;
|
|
41
|
+
if (mtime > latestMtime) {
|
|
42
|
+
latestMtime = mtime;
|
|
43
|
+
latestPath = full;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
catch {
|
|
48
|
+
return null;
|
|
49
|
+
}
|
|
50
|
+
return latestPath;
|
|
51
|
+
}
|
package/dist/utils/fs.js
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* File system utilities
|
|
3
|
+
*/
|
|
4
|
+
import { mkdir, readFile, writeFile } from "fs/promises";
|
|
5
|
+
import { dirname } from "path";
|
|
6
|
+
export async function ensureDir(path) {
|
|
7
|
+
await mkdir(dirname(path), { recursive: true });
|
|
8
|
+
}
|
|
9
|
+
export async function readText(path) {
|
|
10
|
+
return readFile(path, "utf-8");
|
|
11
|
+
}
|
|
12
|
+
export async function writeText(path, content) {
|
|
13
|
+
await ensureDir(path);
|
|
14
|
+
await writeFile(path, content, "utf-8");
|
|
15
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Path resolution for PlanForge (project root, .cursor, plans dir)
|
|
3
|
+
*/
|
|
4
|
+
import { existsSync } from "fs";
|
|
5
|
+
import { resolve, dirname } from "path";
|
|
6
|
+
import { fileURLToPath } from "url";
|
|
7
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
8
|
+
/**
|
|
9
|
+
* Find project root by walking up from cwd until we find planforge.json or .cursor.
|
|
10
|
+
*/
|
|
11
|
+
export function getProjectRoot(cwd = process.cwd()) {
|
|
12
|
+
let dir = resolve(cwd);
|
|
13
|
+
const root = resolve(dir, "..");
|
|
14
|
+
while (dir !== root) {
|
|
15
|
+
if (existsSync(resolve(dir, "planforge.json")) || existsSync(resolve(dir, ".cursor"))) {
|
|
16
|
+
return dir;
|
|
17
|
+
}
|
|
18
|
+
dir = resolve(dir, "..");
|
|
19
|
+
}
|
|
20
|
+
return cwd;
|
|
21
|
+
}
|
|
22
|
+
export function getPlansDir(projectRoot) {
|
|
23
|
+
return resolve(projectRoot, ".cursor", "plans");
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* Resolve path relative to CLI package (for templates).
|
|
27
|
+
*/
|
|
28
|
+
export function getTemplatesRoot() {
|
|
29
|
+
return resolve(__dirname, "..", "..", "..", "..", "templates");
|
|
30
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Parse "## Files Likely to Change" section from plan markdown and return file paths/globs.
|
|
3
|
+
* Lines like "- path" or "- `path`" are extracted; globs are kept as-is for "files to focus on" text.
|
|
4
|
+
*/
|
|
5
|
+
const SECTION_HEADING = "## Files Likely to Change";
|
|
6
|
+
const LIST_ITEM_RE = /^[-*]\s+(?:`([^`]+)`|(.+))$/;
|
|
7
|
+
/**
|
|
8
|
+
* Extract file paths from the "Files Likely to Change" section of a plan document.
|
|
9
|
+
* Returns unique non-empty paths (backticks stripped). Globs are included as strings.
|
|
10
|
+
*/
|
|
11
|
+
export function parseFilesFromPlan(planContent) {
|
|
12
|
+
if (!planContent?.trim())
|
|
13
|
+
return [];
|
|
14
|
+
const lines = planContent.split(/\r?\n/);
|
|
15
|
+
let inSection = false;
|
|
16
|
+
const seen = new Set();
|
|
17
|
+
const paths = [];
|
|
18
|
+
for (const line of lines) {
|
|
19
|
+
const trimmed = line.trim();
|
|
20
|
+
if (trimmed.startsWith("## ")) {
|
|
21
|
+
if (trimmed.toLowerCase().startsWith(SECTION_HEADING.toLowerCase())) {
|
|
22
|
+
inSection = true;
|
|
23
|
+
continue;
|
|
24
|
+
}
|
|
25
|
+
inSection = false;
|
|
26
|
+
continue;
|
|
27
|
+
}
|
|
28
|
+
if (!inSection)
|
|
29
|
+
continue;
|
|
30
|
+
const m = trimmed.match(LIST_ITEM_RE);
|
|
31
|
+
if (!m)
|
|
32
|
+
continue;
|
|
33
|
+
const path = (m[1] ?? m[2] ?? "").trim().replace(/^\/+/, "").replace(/\\/g, "/");
|
|
34
|
+
if (!path || path.includes(".."))
|
|
35
|
+
continue;
|
|
36
|
+
if (seen.has(path))
|
|
37
|
+
continue;
|
|
38
|
+
seen.add(path);
|
|
39
|
+
paths.push(path);
|
|
40
|
+
}
|
|
41
|
+
return paths;
|
|
42
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Read AGENTS.md or CLAUDE.md from project root for plan/implement context. Capped in size.
|
|
3
|
+
*/
|
|
4
|
+
import { readFileSync, existsSync } from "fs";
|
|
5
|
+
import { join } from "path";
|
|
6
|
+
const MAX_PROJECT_CONTEXT_CHARS = 3500;
|
|
7
|
+
const CANDIDATES = ["AGENTS.md", "CLAUDE.md"];
|
|
8
|
+
/**
|
|
9
|
+
* Read project context from AGENTS.md or CLAUDE.md (first existing). Returns undefined if none found or on error.
|
|
10
|
+
*/
|
|
11
|
+
export function getProjectContext(projectRoot) {
|
|
12
|
+
for (const name of CANDIDATES) {
|
|
13
|
+
const path = join(projectRoot, name);
|
|
14
|
+
try {
|
|
15
|
+
if (!existsSync(path))
|
|
16
|
+
continue;
|
|
17
|
+
const content = readFileSync(path, "utf-8").trim();
|
|
18
|
+
if (!content)
|
|
19
|
+
continue;
|
|
20
|
+
if (content.length > MAX_PROJECT_CONTEXT_CHARS) {
|
|
21
|
+
return content.slice(0, MAX_PROJECT_CONTEXT_CHARS) + "\n...(truncated)";
|
|
22
|
+
}
|
|
23
|
+
return content;
|
|
24
|
+
}
|
|
25
|
+
catch {
|
|
26
|
+
/* skip */
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
return undefined;
|
|
30
|
+
}
|
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Collect repository context (git status, diff stat, directory structure) for plan prompts.
|
|
3
|
+
* Optionally appends goal-based ripgrep results when goal is provided. Capped to avoid token overflow.
|
|
4
|
+
*/
|
|
5
|
+
import { spawnSync } from "child_process";
|
|
6
|
+
import { readdirSync } from "fs";
|
|
7
|
+
const MAX_REPO_CONTEXT_CHARS = 3500;
|
|
8
|
+
const MAX_RIPGREP_CONTEXT_CHARS = 2000;
|
|
9
|
+
const MAX_REPO_CONTEXT_WITH_RG_CHARS = 5000;
|
|
10
|
+
const MAX_RIPGREP_FILES = 15;
|
|
11
|
+
const MAX_RECENT_COMMITS_CHARS = 500;
|
|
12
|
+
const MAX_GOAL_FILES_LOG_CHARS = 600;
|
|
13
|
+
const MAX_GOAL_FILES_LOG_FILES = 5;
|
|
14
|
+
const SKIP_DIRS = new Set([".git", "node_modules", ".cursor", "dist", "build", "__pycache__", ".venv", "venv"]);
|
|
15
|
+
function runGit(cwd, args) {
|
|
16
|
+
try {
|
|
17
|
+
const result = spawnSync("git", args, { cwd, encoding: "utf-8", stdio: ["pipe", "pipe", "pipe"] });
|
|
18
|
+
if (result.status !== 0)
|
|
19
|
+
return null;
|
|
20
|
+
return (result.stdout ?? "").trim();
|
|
21
|
+
}
|
|
22
|
+
catch {
|
|
23
|
+
return null;
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
function isGitRepo(cwd) {
|
|
27
|
+
const out = runGit(cwd, ["rev-parse", "--is-inside-work-tree"]);
|
|
28
|
+
return out === "true";
|
|
29
|
+
}
|
|
30
|
+
function getTopLevelDirs(cwd) {
|
|
31
|
+
try {
|
|
32
|
+
const names = readdirSync(cwd, { withFileTypes: true })
|
|
33
|
+
.filter((d) => d.isDirectory() && !SKIP_DIRS.has(d.name))
|
|
34
|
+
.map((d) => d.name)
|
|
35
|
+
.sort();
|
|
36
|
+
return names;
|
|
37
|
+
}
|
|
38
|
+
catch {
|
|
39
|
+
return [];
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* Get recent commits as a single block for repo context. Capped at MAX_RECENT_COMMITS_CHARS.
|
|
44
|
+
*/
|
|
45
|
+
function getRecentCommitsContext(projectRoot) {
|
|
46
|
+
const out = runGit(projectRoot, ["log", "--oneline", "-n", "10"]);
|
|
47
|
+
if (!out)
|
|
48
|
+
return undefined;
|
|
49
|
+
const block = "## recent commits\n" + out;
|
|
50
|
+
if (block.length > MAX_RECENT_COMMITS_CHARS) {
|
|
51
|
+
return block.slice(0, MAX_RECENT_COMMITS_CHARS) + "\n...(truncated)";
|
|
52
|
+
}
|
|
53
|
+
return block;
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* Get list of goal-related file paths via ripgrep (same as getRipgrepContext but returns array). Up to MAX_GOAL_FILES_LOG_FILES.
|
|
57
|
+
*/
|
|
58
|
+
function getRipgrepFileList(projectRoot, goal) {
|
|
59
|
+
const pattern = goal.trim().slice(0, 100);
|
|
60
|
+
if (!pattern)
|
|
61
|
+
return [];
|
|
62
|
+
const globExcludes = [
|
|
63
|
+
"!.git/**",
|
|
64
|
+
"!node_modules/**",
|
|
65
|
+
"!.cursor/**",
|
|
66
|
+
"!dist/**",
|
|
67
|
+
"!build/**",
|
|
68
|
+
"!__pycache__/**",
|
|
69
|
+
"!.venv/**",
|
|
70
|
+
"!venv/**",
|
|
71
|
+
];
|
|
72
|
+
const args = [
|
|
73
|
+
"-F",
|
|
74
|
+
"-l",
|
|
75
|
+
"--max-count",
|
|
76
|
+
"1",
|
|
77
|
+
"-g",
|
|
78
|
+
...globExcludes,
|
|
79
|
+
"--max-filesize",
|
|
80
|
+
"100k",
|
|
81
|
+
"--",
|
|
82
|
+
pattern,
|
|
83
|
+
];
|
|
84
|
+
try {
|
|
85
|
+
const result = spawnSync("rg", args, {
|
|
86
|
+
cwd: projectRoot,
|
|
87
|
+
encoding: "utf-8",
|
|
88
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
89
|
+
timeout: 10000,
|
|
90
|
+
});
|
|
91
|
+
if (result.status !== 0 && result.status !== null)
|
|
92
|
+
return [];
|
|
93
|
+
return (result.stdout ?? "")
|
|
94
|
+
.trim()
|
|
95
|
+
.split(/\r?\n/)
|
|
96
|
+
.map((line) => line.trim())
|
|
97
|
+
.filter((line) => line.length > 0)
|
|
98
|
+
.slice(0, MAX_GOAL_FILES_LOG_FILES);
|
|
99
|
+
}
|
|
100
|
+
catch {
|
|
101
|
+
return [];
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
/**
|
|
105
|
+
* For goal-related files (from ripgrep), get git log --oneline -n 2 per file. Capped at MAX_GOAL_FILES_LOG_CHARS.
|
|
106
|
+
*/
|
|
107
|
+
function getGoalRelatedFilesLogContext(projectRoot, goal) {
|
|
108
|
+
const files = getRipgrepFileList(projectRoot, goal);
|
|
109
|
+
if (files.length === 0)
|
|
110
|
+
return undefined;
|
|
111
|
+
const lines = [];
|
|
112
|
+
for (const file of files) {
|
|
113
|
+
const log = runGit(projectRoot, ["log", "--oneline", "-n", "2", "--", file]);
|
|
114
|
+
if (log) {
|
|
115
|
+
lines.push(file + ":");
|
|
116
|
+
lines.push(log.split(/\r?\n/).map((l) => " " + l).join("\n"));
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
if (lines.length === 0)
|
|
120
|
+
return undefined;
|
|
121
|
+
let block = "## recent changes (goal-related files)\n" + lines.join("\n");
|
|
122
|
+
if (block.length > MAX_GOAL_FILES_LOG_CHARS) {
|
|
123
|
+
block = block.slice(0, MAX_GOAL_FILES_LOG_CHARS) + "\n...(truncated)";
|
|
124
|
+
}
|
|
125
|
+
return block;
|
|
126
|
+
}
|
|
127
|
+
/**
|
|
128
|
+
* Run ripgrep and return matching file paths (one per line), capped in count and length.
|
|
129
|
+
* Returns undefined if rg is not available or fails. Uses fixed-string search (-F) for safety.
|
|
130
|
+
*/
|
|
131
|
+
function getRipgrepContext(projectRoot, goal) {
|
|
132
|
+
const pattern = goal.trim().slice(0, 100);
|
|
133
|
+
if (!pattern)
|
|
134
|
+
return undefined;
|
|
135
|
+
const globExcludes = [
|
|
136
|
+
"!.git/**",
|
|
137
|
+
"!node_modules/**",
|
|
138
|
+
"!.cursor/**",
|
|
139
|
+
"!dist/**",
|
|
140
|
+
"!build/**",
|
|
141
|
+
"!__pycache__/**",
|
|
142
|
+
"!.venv/**",
|
|
143
|
+
"!venv/**",
|
|
144
|
+
];
|
|
145
|
+
const args = [
|
|
146
|
+
"-F",
|
|
147
|
+
"-l",
|
|
148
|
+
"--max-count",
|
|
149
|
+
"1",
|
|
150
|
+
"-g",
|
|
151
|
+
...globExcludes,
|
|
152
|
+
"--max-filesize",
|
|
153
|
+
"100k",
|
|
154
|
+
"--",
|
|
155
|
+
pattern,
|
|
156
|
+
];
|
|
157
|
+
try {
|
|
158
|
+
const result = spawnSync("rg", args, {
|
|
159
|
+
cwd: projectRoot,
|
|
160
|
+
encoding: "utf-8",
|
|
161
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
162
|
+
timeout: 10000,
|
|
163
|
+
});
|
|
164
|
+
if (result.status !== 0 && result.status !== null)
|
|
165
|
+
return undefined;
|
|
166
|
+
const lines = (result.stdout ?? "")
|
|
167
|
+
.trim()
|
|
168
|
+
.split(/\r?\n/)
|
|
169
|
+
.filter((line) => line.trim().length > 0)
|
|
170
|
+
.slice(0, MAX_RIPGREP_FILES);
|
|
171
|
+
if (lines.length === 0)
|
|
172
|
+
return undefined;
|
|
173
|
+
let out = "## ripgrep (goal-related)\n" + lines.join("\n");
|
|
174
|
+
if (out.length > MAX_RIPGREP_CONTEXT_CHARS) {
|
|
175
|
+
out = out.slice(0, MAX_RIPGREP_CONTEXT_CHARS) + "\n...(truncated)";
|
|
176
|
+
}
|
|
177
|
+
return out;
|
|
178
|
+
}
|
|
179
|
+
catch {
|
|
180
|
+
return undefined;
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
/**
|
|
184
|
+
* Build a short repository context string for the planner.
|
|
185
|
+
* If goal is provided and ripgrep is available, appends goal-related file list.
|
|
186
|
+
* Returns undefined if not a git repo (or on error); otherwise git status, diff --stat, top-level dirs, and optionally ripgrep results.
|
|
187
|
+
*/
|
|
188
|
+
export function getRepoContext(projectRoot, goal) {
|
|
189
|
+
if (!isGitRepo(projectRoot))
|
|
190
|
+
return undefined;
|
|
191
|
+
const parts = [];
|
|
192
|
+
const status = runGit(projectRoot, ["status", "--short"]);
|
|
193
|
+
if (status) {
|
|
194
|
+
parts.push("## git status --short\n" + status);
|
|
195
|
+
}
|
|
196
|
+
const diffStat = runGit(projectRoot, ["diff", "--stat"]);
|
|
197
|
+
if (diffStat) {
|
|
198
|
+
parts.push("## git diff --stat\n" + diffStat);
|
|
199
|
+
}
|
|
200
|
+
const cachedStat = runGit(projectRoot, ["diff", "--cached", "--stat"]);
|
|
201
|
+
if (cachedStat) {
|
|
202
|
+
parts.push("## git diff --cached --stat\n" + cachedStat);
|
|
203
|
+
}
|
|
204
|
+
const dirs = getTopLevelDirs(projectRoot);
|
|
205
|
+
if (dirs.length > 0) {
|
|
206
|
+
parts.push("## top-level directories\n" + dirs.join(", "));
|
|
207
|
+
}
|
|
208
|
+
const recentCommits = getRecentCommitsContext(projectRoot);
|
|
209
|
+
if (recentCommits) {
|
|
210
|
+
parts.push(recentCommits);
|
|
211
|
+
}
|
|
212
|
+
if (parts.length === 0 && !goal?.trim())
|
|
213
|
+
return undefined;
|
|
214
|
+
const maxBase = goal?.trim()
|
|
215
|
+
? MAX_REPO_CONTEXT_WITH_RG_CHARS - MAX_RIPGREP_CONTEXT_CHARS - MAX_RECENT_COMMITS_CHARS - MAX_GOAL_FILES_LOG_CHARS - 50
|
|
216
|
+
: MAX_REPO_CONTEXT_CHARS - MAX_RECENT_COMMITS_CHARS;
|
|
217
|
+
let out = parts.length > 0 ? parts.join("\n\n") : "";
|
|
218
|
+
if (out.length > maxBase) {
|
|
219
|
+
out = out.slice(0, maxBase) + "\n...(truncated)";
|
|
220
|
+
}
|
|
221
|
+
if (goal?.trim()) {
|
|
222
|
+
const rgBlock = getRipgrepContext(projectRoot, goal);
|
|
223
|
+
if (rgBlock) {
|
|
224
|
+
out = out ? out + "\n\n" + rgBlock : rgBlock;
|
|
225
|
+
}
|
|
226
|
+
const goalFilesLog = getGoalRelatedFilesLogContext(projectRoot, goal);
|
|
227
|
+
if (goalFilesLog) {
|
|
228
|
+
out = out ? out + "\n\n" + goalFilesLog : goalFilesLog;
|
|
229
|
+
}
|
|
230
|
+
if (out.length > MAX_REPO_CONTEXT_WITH_RG_CHARS) {
|
|
231
|
+
out = out.slice(0, MAX_REPO_CONTEXT_WITH_RG_CHARS) + "\n...(truncated)";
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
else if (out.length > MAX_REPO_CONTEXT_CHARS) {
|
|
235
|
+
out = out.slice(0, MAX_REPO_CONTEXT_CHARS) + "\n...(truncated)";
|
|
236
|
+
}
|
|
237
|
+
return out || undefined;
|
|
238
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shell / process utilities (run claude, codex, etc.)
|
|
3
|
+
*/
|
|
4
|
+
import { execSync, spawnSync } from "child_process";
|
|
5
|
+
import { existsSync } from "fs";
|
|
6
|
+
const isWindows = process.platform === "win32";
|
|
7
|
+
export function runCommand(cmd, args, cwd) {
|
|
8
|
+
const full = [cmd, ...args].join(" ");
|
|
9
|
+
return execSync(full, { encoding: "utf-8", cwd }).trim();
|
|
10
|
+
}
|
|
11
|
+
/**
|
|
12
|
+
* Run a command with stdio inherited so output is visible in the terminal. Returns true if exit code is 0.
|
|
13
|
+
*/
|
|
14
|
+
export function runCommandLive(cmd, args, cwd) {
|
|
15
|
+
const result = spawnSync(cmd, args, { stdio: "inherit", cwd, shell: true });
|
|
16
|
+
return result.status === 0;
|
|
17
|
+
}
|
|
18
|
+
export function hasCommand(cmd) {
|
|
19
|
+
try {
|
|
20
|
+
const check = isWindows ? `where ${cmd}` : `which ${cmd}`;
|
|
21
|
+
execSync(check, { encoding: "utf-8", stdio: "pipe" });
|
|
22
|
+
return true;
|
|
23
|
+
}
|
|
24
|
+
catch {
|
|
25
|
+
return false;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* Resolve full path to command executable (for spawning without shell so arguments are preserved).
|
|
30
|
+
* On Windows, prefers .cmd so we get the real npm global wrapper (e.g. codex.cmd) not the extensionless entry.
|
|
31
|
+
*/
|
|
32
|
+
export function resolveCommandPath(cmd) {
|
|
33
|
+
try {
|
|
34
|
+
const check = isWindows ? `where ${cmd}` : `which ${cmd}`;
|
|
35
|
+
const out = execSync(check, { encoding: "utf-8", stdio: "pipe" });
|
|
36
|
+
const lines = out.split(/[\r\n]+/).map((s) => s.trim()).filter(Boolean);
|
|
37
|
+
if (isWindows && lines.length > 0) {
|
|
38
|
+
const preferred = lines.find((p) => p.toLowerCase().endsWith(".cmd"));
|
|
39
|
+
const chosen = preferred ?? lines.find((p) => existsSync(p)) ?? lines[0];
|
|
40
|
+
return chosen || null;
|
|
41
|
+
}
|
|
42
|
+
return lines[0] || null;
|
|
43
|
+
}
|
|
44
|
+
catch {
|
|
45
|
+
return null;
|
|
46
|
+
}
|
|
47
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "planforge",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"description": "PlanForge CLI - Bring your own AI to Cursor",
|
|
6
|
+
"keywords": ["cursor", "claude", "codex", "ai", "cli", "planning", "planforge"],
|
|
7
|
+
"license": "MIT",
|
|
8
|
+
"repository": {
|
|
9
|
+
"type": "git",
|
|
10
|
+
"url": "https://github.com/planforge/planforge.git"
|
|
11
|
+
},
|
|
12
|
+
"bugs": "https://github.com/planforge/planforge/issues",
|
|
13
|
+
"homepage": "https://github.com/planforge/planforge#readme",
|
|
14
|
+
"files": ["dist"],
|
|
15
|
+
"bin": {
|
|
16
|
+
"planforge": "dist/index.js"
|
|
17
|
+
},
|
|
18
|
+
"scripts": {
|
|
19
|
+
"build": "tsc",
|
|
20
|
+
"dev": "tsc --watch",
|
|
21
|
+
"planforge": "node dist/index.js",
|
|
22
|
+
"install:global": "pnpm run build && npm install -g .",
|
|
23
|
+
"prepublishOnly": "npm run build"
|
|
24
|
+
},
|
|
25
|
+
"dependencies": {
|
|
26
|
+
"@daun_jung/korean-romanizer": "^1.0.1",
|
|
27
|
+
"commander": "^12.0.0",
|
|
28
|
+
"fs-extra": "^11.2.0"
|
|
29
|
+
},
|
|
30
|
+
"devDependencies": {
|
|
31
|
+
"@types/fs-extra": "^11.0.4",
|
|
32
|
+
"@types/node": "^20.0.0",
|
|
33
|
+
"typescript": "^5.0.0"
|
|
34
|
+
},
|
|
35
|
+
"engines": {
|
|
36
|
+
"node": ">=18"
|
|
37
|
+
}
|
|
38
|
+
}
|