@aslomon/effectum 0.1.6 → 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.
@@ -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
+ };