@aslomon/effectum 0.4.0 → 0.5.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,243 @@
1
+ /**
2
+ * Dynamic tool loader — loads JSON-based tool definitions from system/tools/,
3
+ * merges foundation + stack + community definitions, and deduplicates by key.
4
+ *
5
+ * New stacks require only a new JSON file in system/tools/ — zero code changes.
6
+ * Community/local overrides are loaded from .effectum/tools/ and ~/.effectum/tools/.
7
+ */
8
+ "use strict";
9
+
10
+ const fs = require("fs");
11
+ const path = require("path");
12
+ const os = require("os");
13
+
14
+ // ─── JSON loading helpers ────────────────────────────────────────────────────
15
+
16
+ /**
17
+ * Load tools from a JSON file. Returns empty array if file doesn't exist or is invalid.
18
+ * @param {string} filePath - absolute path to JSON file
19
+ * @returns {Array<object>}
20
+ */
21
+ function loadJsonTools(filePath) {
22
+ try {
23
+ if (!fs.existsSync(filePath)) return [];
24
+ const raw = fs.readFileSync(filePath, "utf8");
25
+ const parsed = JSON.parse(raw);
26
+ if (Array.isArray(parsed.tools)) return parsed.tools;
27
+ if (Array.isArray(parsed)) return parsed;
28
+ return [];
29
+ } catch (_) {
30
+ return [];
31
+ }
32
+ }
33
+
34
+ /**
35
+ * Load all JSON files from a directory.
36
+ * Skips files starting with _ (e.g., _schema.json).
37
+ * @param {string} dirPath - directory to scan
38
+ * @returns {Array<object>}
39
+ */
40
+ function loadToolsFromDir(dirPath) {
41
+ const tools = [];
42
+ try {
43
+ if (!fs.existsSync(dirPath)) return tools;
44
+ const files = fs
45
+ .readdirSync(dirPath)
46
+ .filter((f) => f.endsWith(".json") && !f.startsWith("_"));
47
+ for (const file of files) {
48
+ tools.push(...loadJsonTools(path.join(dirPath, file)));
49
+ }
50
+ } catch (_) {
51
+ // Directory doesn't exist or isn't readable
52
+ }
53
+ return tools;
54
+ }
55
+
56
+ // ─── Tool resolution ─────────────────────────────────────────────────────────
57
+
58
+ /**
59
+ * Find the system/tools/ directory relative to this module (which lives in bin/lib/).
60
+ * @returns {string}
61
+ */
62
+ function getSystemToolsDir() {
63
+ return path.resolve(__dirname, "..", "..", "system", "tools");
64
+ }
65
+
66
+ /**
67
+ * Get the JSON filename for a stack key.
68
+ * @param {string} stack - e.g., "nextjs-supabase"
69
+ * @returns {string} - e.g., "nextjs-supabase.json"
70
+ */
71
+ function stackToFilename(stack) {
72
+ return `${stack}.json`;
73
+ }
74
+
75
+ // ─── System basics (pre-config) ──────────────────────────────────────────────
76
+
77
+ /**
78
+ * Get system-level basics that must be checked before any configuration.
79
+ * These are Homebrew (macOS), Git, Node.js, and Claude Code.
80
+ * @returns {Array<object>}
81
+ */
82
+ function getSystemBasics() {
83
+ const platform = os.platform() === "darwin" ? "darwin" : "linux";
84
+ const basics = [];
85
+
86
+ // Homebrew (macOS only)
87
+ if (platform === "darwin") {
88
+ basics.push({
89
+ key: "brew",
90
+ bin: "brew",
91
+ displayName: "Homebrew",
92
+ category: "system",
93
+ why: "Package manager for macOS — needed to install other tools",
94
+ priority: 0,
95
+ autoInstall: true,
96
+ install: {
97
+ darwin:
98
+ '/bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)"',
99
+ },
100
+ check: "brew --version",
101
+ });
102
+ }
103
+
104
+ // Git
105
+ basics.push({
106
+ key: "git",
107
+ bin: "git",
108
+ displayName: "Git",
109
+ category: "system",
110
+ why: "Version control — required for all projects",
111
+ priority: 0,
112
+ autoInstall: true,
113
+ install: {
114
+ darwin: "xcode-select --install",
115
+ linux: "sudo apt install -y git",
116
+ },
117
+ check: "git --version",
118
+ });
119
+
120
+ // Node.js
121
+ basics.push({
122
+ key: "node",
123
+ bin: "node",
124
+ displayName: "Node.js",
125
+ category: "system",
126
+ why: "JavaScript runtime — required for Claude Code and npm tools",
127
+ priority: 0,
128
+ autoInstall: true,
129
+ install: {
130
+ darwin: "brew install node",
131
+ linux: "sudo apt install -y nodejs npm",
132
+ },
133
+ check: "node --version",
134
+ });
135
+
136
+ // Claude Code
137
+ basics.push({
138
+ key: "claude",
139
+ bin: "claude",
140
+ displayName: "Claude Code",
141
+ category: "system",
142
+ why: "AI coding agent — the core of the autonomous workflow",
143
+ priority: 0,
144
+ autoInstall: true,
145
+ install: {
146
+ all: "npm i -g @anthropic-ai/claude-code",
147
+ },
148
+ check: "claude --version",
149
+ });
150
+
151
+ return basics;
152
+ }
153
+
154
+ // ─── Main loader ─────────────────────────────────────────────────────────────
155
+
156
+ /**
157
+ * Load and merge tool definitions for a given stack.
158
+ *
159
+ * Merge order (last wins for duplicate keys):
160
+ * 1. foundation.json (always)
161
+ * 2. stack-specific.json (if exists)
162
+ * 3. Community: <targetDir>/.effectum/tools/*.json
163
+ * 4. Community: ~/.effectum/tools/*.json
164
+ *
165
+ * @param {string} stack - stack key (e.g., "nextjs-supabase", "generic")
166
+ * @param {string} [targetDir] - project directory for local community tools
167
+ * @returns {Array<object>} - deduplicated, priority-sorted tool list
168
+ */
169
+ function loadToolDefinitions(stack, targetDir) {
170
+ const systemDir = getSystemToolsDir();
171
+ const tools = [];
172
+
173
+ // 1. Foundation (always loaded)
174
+ const foundationPath = path.join(systemDir, "foundation.json");
175
+ tools.push(...loadJsonTools(foundationPath));
176
+
177
+ // 2. Stack-specific
178
+ if (stack && stack !== "foundation") {
179
+ const stackPath = path.join(systemDir, stackToFilename(stack));
180
+ tools.push(...loadJsonTools(stackPath));
181
+ }
182
+
183
+ // 3. Community: local project overrides
184
+ if (targetDir) {
185
+ const localToolsDir = path.join(targetDir, ".effectum", "tools");
186
+ tools.push(...loadToolsFromDir(localToolsDir));
187
+ }
188
+
189
+ // 4. Community: global user overrides
190
+ const globalToolsDir = path.join(os.homedir(), ".effectum", "tools");
191
+ tools.push(...loadToolsFromDir(globalToolsDir));
192
+
193
+ return deduplicateByKey(tools);
194
+ }
195
+
196
+ // ─── Deduplication ───────────────────────────────────────────────────────────
197
+
198
+ /**
199
+ * Deduplicate tools by key. Last occurrence wins (community overrides bundled).
200
+ * Result is sorted by priority (ascending).
201
+ * @param {Array<object>} tools
202
+ * @returns {Array<object>}
203
+ */
204
+ function deduplicateByKey(tools) {
205
+ const map = new Map();
206
+ for (const tool of tools) {
207
+ map.set(tool.key, tool);
208
+ }
209
+ return Array.from(map.values()).sort(
210
+ (a, b) => (a.priority ?? 5) - (b.priority ?? 5),
211
+ );
212
+ }
213
+
214
+ // ─── List available stacks ───────────────────────────────────────────────────
215
+
216
+ /**
217
+ * List all available stack JSON files (excluding foundation and _schema).
218
+ * @returns {Array<string>} - stack keys (e.g., ["nextjs-supabase", "python-fastapi"])
219
+ */
220
+ function listAvailableStacks() {
221
+ const systemDir = getSystemToolsDir();
222
+ try {
223
+ return fs
224
+ .readdirSync(systemDir)
225
+ .filter(
226
+ (f) =>
227
+ f.endsWith(".json") && !f.startsWith("_") && f !== "foundation.json",
228
+ )
229
+ .map((f) => f.replace(".json", ""));
230
+ } catch (_) {
231
+ return [];
232
+ }
233
+ }
234
+
235
+ module.exports = {
236
+ loadJsonTools,
237
+ loadToolsFromDir,
238
+ getSystemToolsDir,
239
+ getSystemBasics,
240
+ loadToolDefinitions,
241
+ deduplicateByKey,
242
+ listAvailableStacks,
243
+ };
package/bin/lib/ui.js CHANGED
@@ -21,10 +21,14 @@ const {
21
21
  } = require("./recommendation");
22
22
  const {
23
23
  checkAllTools,
24
+ checkSystemBasics,
24
25
  formatToolStatus,
25
26
  formatInstallInstructions,
27
+ formatInstallPlan,
28
+ formatAuthStatus,
26
29
  installTool,
27
- checkAuth,
30
+ categorizeForInstall,
31
+ checkAllAuth,
28
32
  } = require("./cli-tools");
29
33
 
30
34
  /** @type {import("@clack/prompts")} */
@@ -502,103 +506,159 @@ async function askGitBranch() {
502
506
  return { create: true, name };
503
507
  }
504
508
 
505
- // ─── CLI Tool Check ─────────────────────────────────────────────────────────
509
+ // ─── System Basics Check (Phase 1) ──────────────────────────────────────────
506
510
 
507
511
  /**
508
- * Run CLI tool check and offer installation/auth for missing tools.
512
+ * Check system basics (Homebrew, Git, Node.js, Claude Code) before config.
513
+ * Offers to install missing basics.
514
+ * @returns {Promise<void>}
515
+ */
516
+ async function showSystemCheck() {
517
+ const result = checkSystemBasics();
518
+
519
+ p.note(formatToolStatus(result.tools), "System Basics");
520
+
521
+ if (result.missing.length === 0) {
522
+ p.log.success("All system basics are installed.");
523
+ return;
524
+ }
525
+
526
+ p.log.warn(`${result.missing.length} system tool(s) not found.`);
527
+
528
+ for (const tool of result.missing) {
529
+ const name = tool.displayName || tool.key;
530
+ const wantInstall = await p.confirm({
531
+ message: `Install ${name}? (${tool.why})`,
532
+ initialValue: true,
533
+ });
534
+ handleCancel(wantInstall);
535
+
536
+ if (wantInstall) {
537
+ const s = p.spinner();
538
+ s.start(`Installing ${name}...`);
539
+ const installResult = installTool(tool);
540
+ if (installResult.ok) {
541
+ tool.installed = true;
542
+ s.stop(`${name} installed`);
543
+ } else {
544
+ s.stop(`${name} failed: ${installResult.error || "unknown error"}`);
545
+ p.log.warn(`You can install ${name} manually later.`);
546
+ }
547
+ }
548
+ }
549
+ }
550
+
551
+ // ─── Consolidated Installation Plan (Phase 3) ──────────────────────────────
552
+
553
+ /**
554
+ * Show consolidated installation plan for stack tools and install with one confirmation.
509
555
  * @param {string} stack - selected stack key
556
+ * @param {string} [targetDir] - project directory for community overrides
510
557
  * @returns {Promise<{ tools: Array<object>, missing: Array<object>, installed: Array<object> }>}
511
558
  */
512
- async function showCliToolCheck(stack) {
513
- const result = checkAllTools(stack);
559
+ async function showInstallPlan(stack, targetDir) {
560
+ const result = checkAllTools(stack, targetDir);
514
561
 
515
- p.note(formatToolStatus(result.tools), "CLI Tool Check");
562
+ p.note(formatToolStatus(result.tools), "Stack Tools");
516
563
 
517
564
  if (result.missing.length === 0) {
518
- p.log.success("All CLI tools are installed.");
519
- } else {
520
- p.log.warn(`${result.missing.length} tool(s) not found.`);
521
-
522
- const action = await p.select({
523
- message: "How would you like to handle missing tools?",
524
- options: [
525
- {
526
- value: "install",
527
- label: "Install all missing",
528
- hint: "Run install commands automatically",
529
- },
530
- {
531
- value: "show",
532
- label: "Show commands only",
533
- hint: "Display install commands for manual use",
534
- },
535
- {
536
- value: "skip",
537
- label: "Skip",
538
- hint: "Continue without installing",
539
- },
540
- ],
541
- initialValue: "show",
565
+ p.log.success("All stack tools are installed.");
566
+ return result;
567
+ }
568
+
569
+ // Categorize missing tools
570
+ const plan = categorizeForInstall(result.tools);
571
+
572
+ // Show the consolidated plan
573
+ p.note(formatInstallPlan(plan), "Installation Plan");
574
+
575
+ // Auto-install with one confirmation
576
+ if (plan.autoInstall.length > 0) {
577
+ const names = plan.autoInstall
578
+ .map((t) => t.displayName || t.key)
579
+ .join(", ");
580
+ const confirm = await p.confirm({
581
+ message: `Install ${plan.autoInstall.length} tool(s)? (${names})`,
582
+ initialValue: true,
542
583
  });
543
- handleCancel(action);
584
+ handleCancel(confirm);
544
585
 
545
- if (action === "install") {
546
- for (const tool of result.missing) {
586
+ if (confirm) {
587
+ for (const tool of plan.autoInstall) {
588
+ const name = tool.displayName || tool.key;
547
589
  const s = p.spinner();
548
- s.start(`Installing ${tool.key}...`);
590
+ s.start(`Installing ${name}...`);
549
591
  const installResult = installTool(tool);
550
592
  if (installResult.ok) {
551
- tool.installed = true;
552
- s.stop(`${tool.key} installed`);
593
+ // Update in result too
594
+ const match = result.tools.find((t) => t.key === tool.key);
595
+ if (match) match.installed = true;
596
+ s.stop(`${name} installed`);
553
597
  } else {
554
- s.stop(
555
- `${tool.key} failed: ${installResult.error || "unknown error"}`,
556
- );
598
+ s.stop(`${name} failed: ${installResult.error || "unknown error"}`);
557
599
  }
558
600
  }
559
- } else if (action === "show") {
560
- p.note(formatInstallInstructions(result.missing), "Install Commands");
561
601
  }
562
602
  }
563
603
 
564
- // Auth check for installed tools that need auth
565
- const authTools = result.tools.filter((t) => t.installed && t.auth);
604
+ if (plan.manual.length > 0) {
605
+ p.log.info("Manual setup required for the tools listed above.");
606
+ }
566
607
 
567
- if (authTools.length > 0) {
568
- const authResults = authTools.map((t) => {
569
- const authStatus = checkAuth(t);
570
- return { ...t, ...authStatus };
571
- });
608
+ return result;
609
+ }
572
610
 
573
- const unauthenticated = authResults.filter(
574
- (t) => t.needsAuth && !t.authenticated,
575
- );
611
+ // ─── Auth Flow (Phase 4) ────────────────────────────────────────────────────
576
612
 
577
- if (unauthenticated.length > 0) {
578
- const authLines = authResults.map((t) => {
579
- const icon = t.authenticated ? "\u2705" : "\u274C";
580
- return ` ${icon} ${t.key}${!t.authenticated ? ` — run: ${t.authSetup}` : ""}`;
581
- });
582
- p.note(authLines.join("\n"), "Auth Status");
583
-
584
- const runAuth = await p.confirm({
585
- message:
586
- "Would you like to run auth commands for unauthenticated tools?",
587
- initialValue: false,
588
- });
589
- handleCancel(runAuth);
590
-
591
- if (runAuth) {
592
- p.log.info(
593
- "Auth commands require interactive input. Run these manually:\n" +
594
- unauthenticated.map((t) => ` ${t.key}: ${t.authSetup}`).join("\n"),
595
- );
596
- }
597
- } else {
598
- p.log.success("All tools are authenticated.");
599
- }
613
+ /**
614
+ * Check auth status for installed tools and guide through authentication.
615
+ * @param {Array<object>} tools - tools with `installed` status
616
+ * @returns {Promise<void>}
617
+ */
618
+ async function showAuthCheck(tools) {
619
+ const authResults = checkAllAuth(tools);
620
+
621
+ if (authResults.length === 0) return;
622
+
623
+ p.note(formatAuthStatus(authResults), "Auth Status");
624
+
625
+ const unauthenticated = authResults.filter((t) => !t.authenticated);
626
+
627
+ if (unauthenticated.length === 0) {
628
+ p.log.success("All tools are authenticated.");
629
+ return;
630
+ }
631
+
632
+ p.log.warn(`${unauthenticated.length} tool(s) need authentication.`);
633
+
634
+ const runAuth = await p.confirm({
635
+ message: "Show auth commands for unauthenticated tools?",
636
+ initialValue: true,
637
+ });
638
+ handleCancel(runAuth);
639
+
640
+ if (runAuth) {
641
+ const lines = unauthenticated.map((t) => {
642
+ const name = t.displayName || t.key;
643
+ let line = ` ${name}: ${t.authSetupCmd}`;
644
+ if (t.authUrl) line += `\n Token: ${t.authUrl}`;
645
+ return line;
646
+ });
647
+ p.note(lines.join("\n"), "Run these commands manually");
600
648
  }
649
+ }
650
+
651
+ // ─── Legacy CLI Tool Check (kept for backward compat) ───────────────────────
601
652
 
653
+ /**
654
+ * Run CLI tool check and offer installation/auth for missing tools.
655
+ * @deprecated Use showSystemCheck + showInstallPlan + showAuthCheck instead.
656
+ * @param {string} stack - selected stack key
657
+ * @returns {Promise<{ tools: Array<object>, missing: Array<object>, installed: Array<object> }>}
658
+ */
659
+ async function showCliToolCheck(stack) {
660
+ const result = await showInstallPlan(stack);
661
+ await showAuthCheck(result.tools);
602
662
  return result;
603
663
  }
604
664
 
@@ -668,7 +728,10 @@ module.exports = {
668
728
  askSetupMode,
669
729
  askCustomize,
670
730
  askManual,
671
- // CLI tool check
731
+ // Tool check flow
732
+ showSystemCheck,
733
+ showInstallPlan,
734
+ showAuthCheck,
672
735
  showCliToolCheck,
673
736
  // Legacy / utility prompts
674
737
  askMcpServers,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aslomon/effectum",
3
- "version": "0.4.0",
3
+ "version": "0.5.0",
4
4
  "description": "Autonomous development system for Claude Code \u2014 describe what you want, get production-ready code",
5
5
  "bin": {
6
6
  "effectum": "bin/effectum.js"
@@ -0,0 +1,112 @@
1
+ {
2
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
3
+ "title": "Effectum Tool Definition",
4
+ "description": "Schema for tool definition files in system/tools/. Each file defines tools for a specific stack or category.",
5
+ "type": "object",
6
+ "required": ["tools"],
7
+ "properties": {
8
+ "name": {
9
+ "type": "string",
10
+ "description": "Human-readable name for this tool set (e.g., 'Foundation Tools')"
11
+ },
12
+ "description": {
13
+ "type": "string",
14
+ "description": "Brief description of when these tools are used"
15
+ },
16
+ "tools": {
17
+ "type": "array",
18
+ "items": {
19
+ "type": "object",
20
+ "required": [
21
+ "key",
22
+ "bin",
23
+ "displayName",
24
+ "category",
25
+ "why",
26
+ "priority"
27
+ ],
28
+ "properties": {
29
+ "key": {
30
+ "type": "string",
31
+ "description": "Unique identifier for the tool (e.g., 'git', 'supabase')"
32
+ },
33
+ "bin": {
34
+ "type": "string",
35
+ "description": "Binary name to check in PATH (e.g., 'git', 'supabase')"
36
+ },
37
+ "displayName": {
38
+ "type": "string",
39
+ "description": "Human-readable name (e.g., 'Supabase CLI')"
40
+ },
41
+ "category": {
42
+ "type": "string",
43
+ "enum": ["system", "foundation", "stack", "optional"],
44
+ "description": "Tool category: system (OS-level), foundation (always needed), stack (stack-specific), optional (nice-to-have)"
45
+ },
46
+ "why": {
47
+ "type": "string",
48
+ "description": "Human-readable reason why this tool is needed"
49
+ },
50
+ "priority": {
51
+ "type": "integer",
52
+ "minimum": 0,
53
+ "maximum": 10,
54
+ "description": "Installation priority (0 = highest, 10 = lowest). Determines install order."
55
+ },
56
+ "autoInstall": {
57
+ "type": "boolean",
58
+ "default": true,
59
+ "description": "If false, tool is recommended with a link but not auto-installed (e.g., Docker, Xcode)"
60
+ },
61
+ "install": {
62
+ "type": "object",
63
+ "properties": {
64
+ "darwin": {
65
+ "type": "string",
66
+ "description": "macOS install command"
67
+ },
68
+ "linux": {
69
+ "type": "string",
70
+ "description": "Linux install command"
71
+ },
72
+ "all": {
73
+ "type": "string",
74
+ "description": "Cross-platform install command"
75
+ }
76
+ },
77
+ "description": "Platform-specific install commands. 'all' is used as fallback."
78
+ },
79
+ "check": {
80
+ "type": "string",
81
+ "description": "Command to verify the tool is working (e.g., 'git --version'). Falls back to 'which <bin>'."
82
+ },
83
+ "manualUrl": {
84
+ "type": "string",
85
+ "format": "uri",
86
+ "description": "URL for manual installation instructions (used when autoInstall is false)"
87
+ },
88
+ "auth": {
89
+ "type": "object",
90
+ "properties": {
91
+ "check": {
92
+ "type": "string",
93
+ "description": "Command to check auth status (exit 0 = authenticated)"
94
+ },
95
+ "setup": {
96
+ "type": "string",
97
+ "description": "Command to set up authentication"
98
+ },
99
+ "url": {
100
+ "type": "string",
101
+ "format": "uri",
102
+ "description": "URL where user can create tokens/credentials"
103
+ }
104
+ },
105
+ "required": ["check", "setup"],
106
+ "description": "Authentication configuration. Omit if tool needs no auth."
107
+ }
108
+ }
109
+ }
110
+ }
111
+ }
112
+ }
@@ -0,0 +1,56 @@
1
+ {
2
+ "name": "Foundation Tools",
3
+ "description": "Always-required tools regardless of stack. Loaded for every project.",
4
+ "tools": [
5
+ {
6
+ "key": "git",
7
+ "bin": "git",
8
+ "displayName": "Git",
9
+ "category": "foundation",
10
+ "why": "Version control — required for all projects",
11
+ "priority": 0,
12
+ "autoInstall": true,
13
+ "install": {
14
+ "darwin": "xcode-select --install",
15
+ "linux": "sudo apt install -y git"
16
+ },
17
+ "check": "git --version",
18
+ "auth": {
19
+ "check": "git config user.name && git config user.email",
20
+ "setup": "git config --global user.name \"Your Name\" && git config --global user.email \"you@example.com\""
21
+ }
22
+ },
23
+ {
24
+ "key": "gh",
25
+ "bin": "gh",
26
+ "displayName": "GitHub CLI",
27
+ "category": "foundation",
28
+ "why": "GitHub: Issues, PRs, Code Search, CI status",
29
+ "priority": 1,
30
+ "autoInstall": true,
31
+ "install": {
32
+ "darwin": "brew install gh",
33
+ "linux": "sudo apt install -y gh"
34
+ },
35
+ "check": "gh --version",
36
+ "auth": {
37
+ "check": "gh auth status",
38
+ "setup": "gh auth login",
39
+ "url": "https://github.com/settings/tokens"
40
+ }
41
+ },
42
+ {
43
+ "key": "claude",
44
+ "bin": "claude",
45
+ "displayName": "Claude Code",
46
+ "category": "foundation",
47
+ "why": "AI coding agent — the core of the autonomous workflow",
48
+ "priority": 0,
49
+ "autoInstall": true,
50
+ "install": {
51
+ "all": "npm i -g @anthropic-ai/claude-code"
52
+ },
53
+ "check": "claude --version"
54
+ }
55
+ ]
56
+ }