@aslomon/effectum 0.1.5 → 0.2.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/bin/effectum.js +50 -0
- package/bin/init.js +30 -0
- package/bin/install.js +531 -474
- package/bin/lib/config.js +55 -0
- package/bin/lib/constants.js +197 -0
- package/bin/lib/detect.js +98 -0
- package/bin/lib/stack-parser.js +56 -0
- package/bin/lib/template.js +108 -0
- package/bin/lib/ui.js +221 -0
- package/bin/lib/utils.js +56 -0
- package/bin/reconfigure.js +170 -0
- package/package.json +6 -3
- package/system/commands/setup.md +18 -6
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Read/write .effectum.json configuration file.
|
|
3
|
+
*/
|
|
4
|
+
"use strict";
|
|
5
|
+
|
|
6
|
+
const fs = require("fs");
|
|
7
|
+
const path = require("path");
|
|
8
|
+
|
|
9
|
+
const CONFIG_FILENAME = ".effectum.json";
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Read .effectum.json from a directory.
|
|
13
|
+
* @param {string} dir
|
|
14
|
+
* @returns {object|null} parsed config or null if not found
|
|
15
|
+
*/
|
|
16
|
+
function readConfig(dir) {
|
|
17
|
+
const filePath = path.join(dir, CONFIG_FILENAME);
|
|
18
|
+
if (!fs.existsSync(filePath)) return null;
|
|
19
|
+
try {
|
|
20
|
+
return JSON.parse(fs.readFileSync(filePath, "utf8"));
|
|
21
|
+
} catch (_) {
|
|
22
|
+
return null;
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Write .effectum.json to a directory.
|
|
28
|
+
* @param {string} dir
|
|
29
|
+
* @param {object} config
|
|
30
|
+
*/
|
|
31
|
+
function writeConfig(dir, config) {
|
|
32
|
+
const filePath = path.join(dir, CONFIG_FILENAME);
|
|
33
|
+
const now = new Date().toISOString();
|
|
34
|
+
const data = {
|
|
35
|
+
version: "0.2.0",
|
|
36
|
+
...config,
|
|
37
|
+
updatedAt: now,
|
|
38
|
+
};
|
|
39
|
+
if (!data.createdAt) {
|
|
40
|
+
data.createdAt = now;
|
|
41
|
+
}
|
|
42
|
+
fs.writeFileSync(filePath, JSON.stringify(data, null, 2) + "\n", "utf8");
|
|
43
|
+
return filePath;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Check if .effectum.json exists in a directory.
|
|
48
|
+
* @param {string} dir
|
|
49
|
+
* @returns {boolean}
|
|
50
|
+
*/
|
|
51
|
+
function configExists(dir) {
|
|
52
|
+
return fs.existsSync(path.join(dir, CONFIG_FILENAME));
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
module.exports = { readConfig, writeConfig, configExists, CONFIG_FILENAME };
|
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared constants for the Effectum CLI.
|
|
3
|
+
* Autonomy levels, formatter mappings, MCP server definitions, stack choices, language choices.
|
|
4
|
+
*/
|
|
5
|
+
"use strict";
|
|
6
|
+
|
|
7
|
+
/** @type {Record<string, { defaultMode: string, permissions: { allow: string[] } }>} */
|
|
8
|
+
const AUTONOMY_MAP = {
|
|
9
|
+
conservative: {
|
|
10
|
+
defaultMode: "default",
|
|
11
|
+
permissions: {
|
|
12
|
+
allow: ["Read(*)", "Glob(*)", "Grep(*)", "WebFetch(*)", "WebSearch(*)"],
|
|
13
|
+
},
|
|
14
|
+
},
|
|
15
|
+
standard: {
|
|
16
|
+
defaultMode: "default",
|
|
17
|
+
permissions: {
|
|
18
|
+
allow: [
|
|
19
|
+
"Bash(*)",
|
|
20
|
+
"Read(*)",
|
|
21
|
+
"Write(*)",
|
|
22
|
+
"Edit(*)",
|
|
23
|
+
"Glob(*)",
|
|
24
|
+
"Grep(*)",
|
|
25
|
+
"WebFetch(*)",
|
|
26
|
+
"WebSearch(*)",
|
|
27
|
+
"Task(*)",
|
|
28
|
+
"NotebookEdit(*)",
|
|
29
|
+
],
|
|
30
|
+
},
|
|
31
|
+
},
|
|
32
|
+
full: {
|
|
33
|
+
defaultMode: "bypassPermissions",
|
|
34
|
+
permissions: {
|
|
35
|
+
allow: [
|
|
36
|
+
"Bash(*)",
|
|
37
|
+
"Read(*)",
|
|
38
|
+
"Write(*)",
|
|
39
|
+
"Edit(*)",
|
|
40
|
+
"Glob(*)",
|
|
41
|
+
"Grep(*)",
|
|
42
|
+
"WebFetch(*)",
|
|
43
|
+
"WebSearch(*)",
|
|
44
|
+
"Task(*)",
|
|
45
|
+
"NotebookEdit(*)",
|
|
46
|
+
],
|
|
47
|
+
},
|
|
48
|
+
},
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
/** @type {Record<string, { command: string, name: string, glob: string }>} */
|
|
52
|
+
const FORMATTER_MAP = {
|
|
53
|
+
"nextjs-supabase": {
|
|
54
|
+
command: "npx prettier --write",
|
|
55
|
+
name: "Prettier",
|
|
56
|
+
glob: "ts|tsx|js|jsx|json|css|md",
|
|
57
|
+
},
|
|
58
|
+
"python-fastapi": {
|
|
59
|
+
command: "ruff format",
|
|
60
|
+
name: "Ruff",
|
|
61
|
+
glob: "py",
|
|
62
|
+
},
|
|
63
|
+
"swift-ios": {
|
|
64
|
+
command: "swift-format format -i",
|
|
65
|
+
name: "swift-format",
|
|
66
|
+
glob: "swift",
|
|
67
|
+
},
|
|
68
|
+
generic: {
|
|
69
|
+
command: "echo no-formatter-configured",
|
|
70
|
+
name: "None",
|
|
71
|
+
glob: "*",
|
|
72
|
+
},
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
/** @type {Array<{ key: string, label: string, package: string, desc: string, config: object }>} */
|
|
76
|
+
const MCP_SERVERS = [
|
|
77
|
+
{
|
|
78
|
+
key: "context7",
|
|
79
|
+
label: "Context7",
|
|
80
|
+
package: "@upstash/context7-mcp",
|
|
81
|
+
desc: "Up-to-date library docs for Claude",
|
|
82
|
+
config: {
|
|
83
|
+
command: "npx",
|
|
84
|
+
args: ["-y", "@upstash/context7-mcp"],
|
|
85
|
+
},
|
|
86
|
+
},
|
|
87
|
+
{
|
|
88
|
+
key: "playwright",
|
|
89
|
+
label: "Playwright MCP",
|
|
90
|
+
package: "@playwright/mcp",
|
|
91
|
+
desc: "E2E browser automation — required for /e2e",
|
|
92
|
+
config: {
|
|
93
|
+
command: "npx",
|
|
94
|
+
args: ["-y", "@playwright/mcp"],
|
|
95
|
+
},
|
|
96
|
+
},
|
|
97
|
+
{
|
|
98
|
+
key: "sequential-thinking",
|
|
99
|
+
label: "Sequential Thinking",
|
|
100
|
+
package: "@modelcontextprotocol/server-sequential-thinking",
|
|
101
|
+
desc: "Complex planning and multi-step reasoning",
|
|
102
|
+
config: {
|
|
103
|
+
command: "npx",
|
|
104
|
+
args: ["-y", "@modelcontextprotocol/server-sequential-thinking"],
|
|
105
|
+
},
|
|
106
|
+
},
|
|
107
|
+
{
|
|
108
|
+
key: "filesystem",
|
|
109
|
+
label: "Filesystem",
|
|
110
|
+
package: "@modelcontextprotocol/server-filesystem",
|
|
111
|
+
desc: "File operations (read/write/search)",
|
|
112
|
+
configFn: (cwd) => ({
|
|
113
|
+
command: "npx",
|
|
114
|
+
args: ["-y", "@modelcontextprotocol/server-filesystem", cwd],
|
|
115
|
+
}),
|
|
116
|
+
},
|
|
117
|
+
];
|
|
118
|
+
|
|
119
|
+
/** @type {Array<{ value: string, label: string, hint: string }>} */
|
|
120
|
+
const STACK_CHOICES = [
|
|
121
|
+
{
|
|
122
|
+
value: "nextjs-supabase",
|
|
123
|
+
label: "Next.js + Supabase",
|
|
124
|
+
hint: "Full-stack TypeScript with Tailwind, Shadcn, Supabase",
|
|
125
|
+
},
|
|
126
|
+
{
|
|
127
|
+
value: "python-fastapi",
|
|
128
|
+
label: "Python + FastAPI",
|
|
129
|
+
hint: "Backend APIs with Pydantic, SQLAlchemy, Alembic",
|
|
130
|
+
},
|
|
131
|
+
{
|
|
132
|
+
value: "swift-ios",
|
|
133
|
+
label: "Swift / SwiftUI",
|
|
134
|
+
hint: "Native Apple apps with SwiftData, SPM",
|
|
135
|
+
},
|
|
136
|
+
{
|
|
137
|
+
value: "generic",
|
|
138
|
+
label: "Generic",
|
|
139
|
+
hint: "Stack-agnostic baseline — customize after setup",
|
|
140
|
+
},
|
|
141
|
+
];
|
|
142
|
+
|
|
143
|
+
/** @type {Array<{ value: string, label: string, hint: string }>} */
|
|
144
|
+
const LANGUAGE_CHOICES = [
|
|
145
|
+
{
|
|
146
|
+
value: "english",
|
|
147
|
+
label: "English",
|
|
148
|
+
hint: "Communicate in English",
|
|
149
|
+
},
|
|
150
|
+
{
|
|
151
|
+
value: "german",
|
|
152
|
+
label: "Deutsch (du/informal)",
|
|
153
|
+
hint: "German, informal",
|
|
154
|
+
},
|
|
155
|
+
{
|
|
156
|
+
value: "custom",
|
|
157
|
+
label: "Custom",
|
|
158
|
+
hint: "Enter your own language instruction",
|
|
159
|
+
},
|
|
160
|
+
];
|
|
161
|
+
|
|
162
|
+
/** @type {Record<string, string>} */
|
|
163
|
+
const LANGUAGE_INSTRUCTIONS = {
|
|
164
|
+
english:
|
|
165
|
+
"Speak English with the user. All code, comments, commits, and docs in English.",
|
|
166
|
+
german:
|
|
167
|
+
"Speak German (du/informal) with the user. All code, comments, commits, and docs in English.",
|
|
168
|
+
};
|
|
169
|
+
|
|
170
|
+
/** @type {Array<{ value: string, label: string, hint: string }>} */
|
|
171
|
+
const AUTONOMY_CHOICES = [
|
|
172
|
+
{
|
|
173
|
+
value: "conservative",
|
|
174
|
+
label: "Conservative",
|
|
175
|
+
hint: "Read-only tools allowed, ask before write/execute",
|
|
176
|
+
},
|
|
177
|
+
{
|
|
178
|
+
value: "standard",
|
|
179
|
+
label: "Standard",
|
|
180
|
+
hint: "Read + Write + Bash allowed, ask for dangerous ops",
|
|
181
|
+
},
|
|
182
|
+
{
|
|
183
|
+
value: "full",
|
|
184
|
+
label: "Full Autonomy",
|
|
185
|
+
hint: "Bypass all permission prompts — trust Claude fully",
|
|
186
|
+
},
|
|
187
|
+
];
|
|
188
|
+
|
|
189
|
+
module.exports = {
|
|
190
|
+
AUTONOMY_MAP,
|
|
191
|
+
FORMATTER_MAP,
|
|
192
|
+
MCP_SERVERS,
|
|
193
|
+
STACK_CHOICES,
|
|
194
|
+
LANGUAGE_CHOICES,
|
|
195
|
+
LANGUAGE_INSTRUCTIONS,
|
|
196
|
+
AUTONOMY_CHOICES,
|
|
197
|
+
};
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Auto-detection: project name, tech stack, package manager.
|
|
3
|
+
*/
|
|
4
|
+
"use strict";
|
|
5
|
+
|
|
6
|
+
const fs = require("fs");
|
|
7
|
+
const path = require("path");
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Detect project name from directory name.
|
|
11
|
+
* @param {string} dir
|
|
12
|
+
* @returns {string}
|
|
13
|
+
*/
|
|
14
|
+
function detectProjectName(dir) {
|
|
15
|
+
return path.basename(path.resolve(dir));
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Detect tech stack from project files.
|
|
20
|
+
* @param {string} dir
|
|
21
|
+
* @returns {string|null} stack key or null
|
|
22
|
+
*/
|
|
23
|
+
function detectStack(dir) {
|
|
24
|
+
const packageJsonPath = path.join(dir, "package.json");
|
|
25
|
+
if (fs.existsSync(packageJsonPath)) {
|
|
26
|
+
try {
|
|
27
|
+
const pkg = JSON.parse(fs.readFileSync(packageJsonPath, "utf8"));
|
|
28
|
+
const allDeps = {
|
|
29
|
+
...pkg.dependencies,
|
|
30
|
+
...pkg.devDependencies,
|
|
31
|
+
};
|
|
32
|
+
if (allDeps["next"] && allDeps["@supabase/supabase-js"]) {
|
|
33
|
+
return "nextjs-supabase";
|
|
34
|
+
}
|
|
35
|
+
if (allDeps["next"]) {
|
|
36
|
+
return "nextjs-supabase";
|
|
37
|
+
}
|
|
38
|
+
} catch (_) {
|
|
39
|
+
// ignore parse errors
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const pyprojectPath = path.join(dir, "pyproject.toml");
|
|
44
|
+
const requirementsPath = path.join(dir, "requirements.txt");
|
|
45
|
+
if (fs.existsSync(pyprojectPath) || fs.existsSync(requirementsPath)) {
|
|
46
|
+
return "python-fastapi";
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const packageSwiftPath = path.join(dir, "Package.swift");
|
|
50
|
+
const xcodeprojExists = fs
|
|
51
|
+
.readdirSync(dir)
|
|
52
|
+
.some((f) => f.endsWith(".xcodeproj") || f.endsWith(".xcworkspace"));
|
|
53
|
+
if (fs.existsSync(packageSwiftPath) || xcodeprojExists) {
|
|
54
|
+
return "swift-ios";
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
return null;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Detect package manager from lock files.
|
|
62
|
+
* @param {string} dir
|
|
63
|
+
* @returns {string}
|
|
64
|
+
*/
|
|
65
|
+
function detectPackageManager(dir) {
|
|
66
|
+
if (fs.existsSync(path.join(dir, "pnpm-lock.yaml"))) return "pnpm";
|
|
67
|
+
if (fs.existsSync(path.join(dir, "yarn.lock"))) return "yarn";
|
|
68
|
+
if (fs.existsSync(path.join(dir, "bun.lockb"))) return "bun";
|
|
69
|
+
if (fs.existsSync(path.join(dir, "package-lock.json"))) return "npm";
|
|
70
|
+
if (fs.existsSync(path.join(dir, "Pipfile.lock"))) return "pipenv";
|
|
71
|
+
if (fs.existsSync(path.join(dir, "poetry.lock"))) return "poetry";
|
|
72
|
+
if (fs.existsSync(path.join(dir, "uv.lock"))) return "uv";
|
|
73
|
+
if (fs.existsSync(path.join(dir, "pyproject.toml"))) return "uv";
|
|
74
|
+
if (fs.existsSync(path.join(dir, "Package.swift")))
|
|
75
|
+
return "swift package (SPM)";
|
|
76
|
+
if (fs.existsSync(path.join(dir, "package.json"))) return "npm";
|
|
77
|
+
return "npm";
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Run all detections and return a summary.
|
|
82
|
+
* @param {string} dir
|
|
83
|
+
* @returns {{ projectName: string, stack: string|null, packageManager: string }}
|
|
84
|
+
*/
|
|
85
|
+
function detectAll(dir) {
|
|
86
|
+
return {
|
|
87
|
+
projectName: detectProjectName(dir),
|
|
88
|
+
stack: detectStack(dir),
|
|
89
|
+
packageManager: detectPackageManager(dir),
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
module.exports = {
|
|
94
|
+
detectProjectName,
|
|
95
|
+
detectStack,
|
|
96
|
+
detectPackageManager,
|
|
97
|
+
detectAll,
|
|
98
|
+
};
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Parse stack preset .md files into key-value maps.
|
|
3
|
+
*/
|
|
4
|
+
"use strict";
|
|
5
|
+
|
|
6
|
+
const fs = require("fs");
|
|
7
|
+
const path = require("path");
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Parse a stack preset markdown file into a sections map.
|
|
11
|
+
* Extracts content from sections like:
|
|
12
|
+
* ## KEY_NAME
|
|
13
|
+
* ```
|
|
14
|
+
* value content
|
|
15
|
+
* ```
|
|
16
|
+
* @param {string} content - markdown content
|
|
17
|
+
* @returns {Record<string, string>}
|
|
18
|
+
*/
|
|
19
|
+
function parseStackPreset(content) {
|
|
20
|
+
const sections = {};
|
|
21
|
+
const regex = /^## (\w+)\s*\n+`{3,4}\n([\s\S]*?)`{3,4}/gm;
|
|
22
|
+
let match;
|
|
23
|
+
while ((match = regex.exec(content)) !== null) {
|
|
24
|
+
sections[match[1]] = match[2].trim();
|
|
25
|
+
}
|
|
26
|
+
return sections;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Load and parse a stack preset file by stack key.
|
|
31
|
+
* Looks in the .effectum/stacks/ directory first (installed copy),
|
|
32
|
+
* then falls back to system/stacks/ (repo source).
|
|
33
|
+
* @param {string} stackKey - e.g. 'nextjs-supabase'
|
|
34
|
+
* @param {string} targetDir - project directory
|
|
35
|
+
* @param {string} repoRoot - effectum repo root
|
|
36
|
+
* @returns {Record<string, string>}
|
|
37
|
+
*/
|
|
38
|
+
function loadStackPreset(stackKey, targetDir, repoRoot) {
|
|
39
|
+
const candidates = [
|
|
40
|
+
path.join(targetDir, ".effectum", "stacks", `${stackKey}.md`),
|
|
41
|
+
path.join(repoRoot, "system", "stacks", `${stackKey}.md`),
|
|
42
|
+
];
|
|
43
|
+
|
|
44
|
+
for (const filePath of candidates) {
|
|
45
|
+
if (fs.existsSync(filePath)) {
|
|
46
|
+
const content = fs.readFileSync(filePath, "utf8");
|
|
47
|
+
return parseStackPreset(content);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
throw new Error(
|
|
52
|
+
`Stack preset "${stackKey}" not found. Searched:\n ${candidates.join("\n ")}`,
|
|
53
|
+
);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
module.exports = { parseStackPreset, loadStackPreset };
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Template substitution engine.
|
|
3
|
+
* Reads template files, replaces all {{PLACEHOLDER}} tokens with values
|
|
4
|
+
* from the stack preset and user config.
|
|
5
|
+
*/
|
|
6
|
+
"use strict";
|
|
7
|
+
|
|
8
|
+
const fs = require("fs");
|
|
9
|
+
const path = require("path");
|
|
10
|
+
const { FORMATTER_MAP, LANGUAGE_INSTRUCTIONS } = require("./constants");
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Build a substitution map from user config and parsed stack sections.
|
|
14
|
+
* @param {object} config - user config from .effectum.json
|
|
15
|
+
* @param {Record<string, string>} stackSections - parsed stack preset
|
|
16
|
+
* @returns {Record<string, string>}
|
|
17
|
+
*/
|
|
18
|
+
function buildSubstitutionMap(config, stackSections) {
|
|
19
|
+
const formatter = FORMATTER_MAP[config.stack] || FORMATTER_MAP.generic;
|
|
20
|
+
const langInstruction =
|
|
21
|
+
LANGUAGE_INSTRUCTIONS[config.language] ||
|
|
22
|
+
config.customLanguage ||
|
|
23
|
+
LANGUAGE_INSTRUCTIONS.english;
|
|
24
|
+
|
|
25
|
+
return {
|
|
26
|
+
PROJECT_NAME: config.projectName,
|
|
27
|
+
LANGUAGE: langInstruction,
|
|
28
|
+
TECH_STACK: stackSections.TECH_STACK || "[Not configured]",
|
|
29
|
+
ARCHITECTURE_PRINCIPLES:
|
|
30
|
+
stackSections.ARCHITECTURE_PRINCIPLES || "[Not configured]",
|
|
31
|
+
PROJECT_STRUCTURE: stackSections.PROJECT_STRUCTURE || "[Not configured]",
|
|
32
|
+
QUALITY_GATES: stackSections.QUALITY_GATES || "[Not configured]",
|
|
33
|
+
STACK_SPECIFIC_GUARDRAILS:
|
|
34
|
+
stackSections.STACK_SPECIFIC_GUARDRAILS || "[Not configured]",
|
|
35
|
+
FORMATTER: formatter.command,
|
|
36
|
+
FORMATTER_NAME: formatter.name,
|
|
37
|
+
FORMATTER_GLOB: formatter.glob,
|
|
38
|
+
PACKAGE_MANAGER: config.packageManager,
|
|
39
|
+
TOOL_SPECIFIC_GUARDRAILS:
|
|
40
|
+
stackSections.TOOL_SPECIFIC_GUARDRAILS || "[Not configured]",
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Replace all {{KEY}} placeholders in a string.
|
|
46
|
+
* @param {string} content
|
|
47
|
+
* @param {Record<string, string>} vars
|
|
48
|
+
* @returns {string}
|
|
49
|
+
*/
|
|
50
|
+
function substituteAll(content, vars) {
|
|
51
|
+
let result = content;
|
|
52
|
+
for (const [key, value] of Object.entries(vars)) {
|
|
53
|
+
const pattern = new RegExp(`\\{\\{${key}\\}\\}`, "g");
|
|
54
|
+
result = result.replace(pattern, value);
|
|
55
|
+
}
|
|
56
|
+
return result;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Check if any {{...}} placeholders remain.
|
|
61
|
+
* @param {string} content
|
|
62
|
+
* @returns {string[]} list of remaining placeholder names
|
|
63
|
+
*/
|
|
64
|
+
function findRemainingPlaceholders(content) {
|
|
65
|
+
const matches = content.match(/\{\{(\w+)\}\}/g) || [];
|
|
66
|
+
return [...new Set(matches)];
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Read a template file, substitute all placeholders, return result.
|
|
71
|
+
* @param {string} templatePath
|
|
72
|
+
* @param {Record<string, string>} vars
|
|
73
|
+
* @returns {{ content: string, remaining: string[] }}
|
|
74
|
+
*/
|
|
75
|
+
function renderTemplate(templatePath, vars) {
|
|
76
|
+
const raw = fs.readFileSync(templatePath, "utf8");
|
|
77
|
+
const content = substituteAll(raw, vars);
|
|
78
|
+
const remaining = findRemainingPlaceholders(content);
|
|
79
|
+
return { content, remaining };
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Find template file path. Checks .effectum/templates/ first, then system/templates/.
|
|
84
|
+
* @param {string} filename - e.g. 'CLAUDE.md.tmpl'
|
|
85
|
+
* @param {string} targetDir
|
|
86
|
+
* @param {string} repoRoot
|
|
87
|
+
* @returns {string}
|
|
88
|
+
*/
|
|
89
|
+
function findTemplatePath(filename, targetDir, repoRoot) {
|
|
90
|
+
const candidates = [
|
|
91
|
+
path.join(targetDir, ".effectum", "templates", filename),
|
|
92
|
+
path.join(repoRoot, "system", "templates", filename),
|
|
93
|
+
];
|
|
94
|
+
for (const p of candidates) {
|
|
95
|
+
if (fs.existsSync(p)) return p;
|
|
96
|
+
}
|
|
97
|
+
throw new Error(
|
|
98
|
+
`Template "${filename}" not found. Searched:\n ${candidates.join("\n ")}`,
|
|
99
|
+
);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
module.exports = {
|
|
103
|
+
buildSubstitutionMap,
|
|
104
|
+
substituteAll,
|
|
105
|
+
findRemainingPlaceholders,
|
|
106
|
+
renderTemplate,
|
|
107
|
+
findTemplatePath,
|
|
108
|
+
};
|