@meshxdata/fops 0.0.1 → 0.0.4

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.
Files changed (52) hide show
  1. package/README.md +62 -40
  2. package/package.json +4 -3
  3. package/src/agent/agent.js +161 -68
  4. package/src/agent/agents.js +224 -0
  5. package/src/agent/context.js +287 -96
  6. package/src/agent/index.js +1 -0
  7. package/src/agent/llm.js +134 -20
  8. package/src/auth/coda.js +128 -0
  9. package/src/auth/index.js +1 -0
  10. package/src/auth/login.js +13 -13
  11. package/src/auth/oauth.js +4 -4
  12. package/src/commands/index.js +94 -21
  13. package/src/config.js +2 -2
  14. package/src/doctor.js +208 -22
  15. package/src/feature-flags.js +197 -0
  16. package/src/plugins/api.js +23 -0
  17. package/src/plugins/builtins/stack-api.js +36 -0
  18. package/src/plugins/index.js +1 -0
  19. package/src/plugins/knowledge.js +124 -0
  20. package/src/plugins/loader.js +67 -0
  21. package/src/plugins/registry.js +3 -0
  22. package/src/project.js +20 -1
  23. package/src/setup/aws.js +7 -7
  24. package/src/setup/setup.js +18 -12
  25. package/src/setup/wizard.js +86 -15
  26. package/src/shell.js +2 -2
  27. package/src/skills/foundation/SKILL.md +200 -66
  28. package/src/ui/confirm.js +3 -2
  29. package/src/ui/input.js +31 -34
  30. package/src/ui/spinner.js +39 -13
  31. package/src/ui/streaming.js +2 -2
  32. package/STRUCTURE.md +0 -43
  33. package/src/agent/agent.test.js +0 -233
  34. package/src/agent/context.test.js +0 -81
  35. package/src/agent/llm.test.js +0 -139
  36. package/src/auth/keychain.test.js +0 -185
  37. package/src/auth/login.test.js +0 -192
  38. package/src/auth/oauth.test.js +0 -118
  39. package/src/auth/resolve.test.js +0 -153
  40. package/src/config.test.js +0 -70
  41. package/src/doctor.test.js +0 -134
  42. package/src/plugins/api.test.js +0 -95
  43. package/src/plugins/discovery.test.js +0 -92
  44. package/src/plugins/hooks.test.js +0 -118
  45. package/src/plugins/manifest.test.js +0 -106
  46. package/src/plugins/registry.test.js +0 -43
  47. package/src/plugins/skills.test.js +0 -173
  48. package/src/project.test.js +0 -196
  49. package/src/setup/aws.test.js +0 -280
  50. package/src/shell.test.js +0 -72
  51. package/src/ui/banner.test.js +0 -97
  52. package/src/ui/spinner.test.js +0 -29
package/src/doctor.js CHANGED
@@ -22,7 +22,7 @@ const KEY_PORTS = {
22
22
 
23
23
  function header(title) {
24
24
  console.log(chalk.bold.cyan(`\n ${title}`));
25
- console.log(chalk.gray(" " + "─".repeat(40)));
25
+ console.log(chalk.dim(" " + "─".repeat(40)));
26
26
  }
27
27
 
28
28
  async function checkPort(port) {
@@ -100,15 +100,15 @@ export async function runDoctor(opts = {}, registry = null) {
100
100
  const fixes = []; // collect fix actions to run at the end
101
101
 
102
102
  const ok = (name, detail) => {
103
- console.log(chalk.green(" ✓ ") + name + (detail ? chalk.gray(` — ${detail}`) : ""));
103
+ console.log(chalk.green(" ✓ ") + name + (detail ? chalk.dim(` — ${detail}`) : ""));
104
104
  passed++;
105
105
  };
106
106
  const warn = (name, detail) => {
107
- console.log(chalk.yellow(" ⚠ ") + name + (detail ? chalk.gray(` — ${detail}`) : ""));
107
+ console.log(chalk.yellow(" ⚠ ") + name + (detail ? chalk.dim(` — ${detail}`) : ""));
108
108
  warned++;
109
109
  };
110
110
  const fail = (name, detail, fixFn) => {
111
- console.log(chalk.red(" ✗ ") + name + (detail ? chalk.gray(` — ${detail}`) : ""));
111
+ console.log(chalk.red(" ✗ ") + name + (detail ? chalk.dim(` — ${detail}`) : ""));
112
112
  failed++;
113
113
  if (fixFn) fixes.push({ name, fn: fixFn });
114
114
  };
@@ -116,6 +116,29 @@ export async function runDoctor(opts = {}, registry = null) {
116
116
  // ── Prerequisites ──────────────────────────────────
117
117
  header("Prerequisites");
118
118
 
119
+ // winget (Windows only — needed to install other tools)
120
+ let hasWinget = false;
121
+ if (process.platform === "win32") {
122
+ const wingetVer = await cmdVersion("winget");
123
+ if (wingetVer) {
124
+ ok("winget available", wingetVer);
125
+ hasWinget = true;
126
+ } else {
127
+ fail("winget not found", "needed to install Docker", async () => {
128
+ console.log(chalk.cyan(" ▶ Installing winget via Add-AppxPackage…"));
129
+ await execa("powershell", ["-Command", [
130
+ "$url = (Invoke-RestMethod 'https://api.github.com/repos/microsoft/winget-cli/releases/latest').assets",
131
+ "| Where-Object { $_.name -match '.msixbundle$' } | Select-Object -First 1 -ExpandProperty browser_download_url;",
132
+ "$tmp = Join-Path $env:TEMP 'winget.msixbundle';",
133
+ "Invoke-WebRequest -Uri $url -OutFile $tmp;",
134
+ "Add-AppxPackage -Path $tmp;",
135
+ "Remove-Item $tmp",
136
+ ].join(" ")], { stdio: "inherit", timeout: 120_000 });
137
+ hasWinget = true;
138
+ });
139
+ }
140
+ }
141
+
119
142
  // Docker
120
143
  const dockerVer = await cmdVersion("docker");
121
144
  if (dockerVer) {
@@ -124,12 +147,91 @@ export async function runDoctor(opts = {}, registry = null) {
124
147
  await execa("docker", ["info"], { timeout: 5000 });
125
148
  ok("Docker running", dockerVer);
126
149
  } catch {
127
- fail("Docker daemon not running", "start Docker Desktop or dockerd");
150
+ fail("Docker daemon not running", "start Docker Desktop or dockerd", async () => {
151
+ if (process.platform === "darwin") {
152
+ console.log(chalk.cyan(" ▶ open -a Docker"));
153
+ await execa("open", ["-a", "Docker"], { timeout: 10000 });
154
+ } else if (process.platform === "win32") {
155
+ console.log(chalk.cyan(' ▶ start "" "Docker Desktop"'));
156
+ await execa("cmd", ["/c", "start", "", "Docker Desktop"], { timeout: 10000 });
157
+ } else {
158
+ console.log(chalk.cyan(" ▶ sudo systemctl start docker"));
159
+ await execa("sudo", ["systemctl", "start", "docker"], { stdio: "inherit", timeout: 30000 });
160
+ return;
161
+ }
162
+ // macOS / Windows: wait for daemon to become ready
163
+ console.log(chalk.dim(" Waiting for Docker daemon to start…"));
164
+ for (let i = 0; i < 30; i++) {
165
+ await new Promise((r) => setTimeout(r, 2000));
166
+ try {
167
+ await execa("docker", ["info"], { timeout: 5000 });
168
+ return;
169
+ } catch {}
170
+ }
171
+ throw new Error("Docker daemon did not start within 60 s");
172
+ });
128
173
  }
129
174
  } else {
130
- fail("Docker not found", "install from docker.com");
175
+ fail("Docker not found", "install from docker.com", async () => {
176
+ if (process.platform === "darwin") {
177
+ let hasBrew = false;
178
+ try { await execa("brew", ["--version"]); hasBrew = true; } catch {}
179
+ if (!hasBrew) {
180
+ console.log(chalk.cyan(" ▶ Installing Homebrew…"));
181
+ await execa("bash", ["-c", 'NONINTERACTIVE=1 /bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)"'], {
182
+ stdio: "inherit", timeout: 300_000,
183
+ });
184
+ // Add brew to PATH for Apple Silicon
185
+ const brewPaths = ["/opt/homebrew/bin/brew", "/usr/local/bin/brew"];
186
+ for (const bp of brewPaths) {
187
+ if (fs.existsSync(bp)) {
188
+ const { stdout } = await execa(bp, ["shellenv"], { timeout: 5000 });
189
+ for (const line of stdout.split("\n")) {
190
+ const m = line.match(/export\s+PATH="([^"]+)"/);
191
+ if (m) process.env.PATH = m[1] + ":" + process.env.PATH;
192
+ }
193
+ break;
194
+ }
195
+ }
196
+ hasBrew = true;
197
+ }
198
+ console.log(chalk.cyan(" ▶ brew install --cask docker"));
199
+ await execa("brew", ["install", "--cask", "docker"], { stdio: "inherit", timeout: 300_000 });
200
+ console.log(chalk.cyan(" ▶ open -a Docker"));
201
+ await execa("open", ["-a", "Docker"], { timeout: 10000 });
202
+ } else if (process.platform === "win32") {
203
+ if (!hasWinget) throw new Error("winget is required to install Docker — fix winget first");
204
+ console.log(chalk.cyan(" ▶ winget install Docker.DockerDesktop"));
205
+ await execa("winget", ["install", "Docker.DockerDesktop", "--accept-source-agreements", "--accept-package-agreements"], {
206
+ stdio: "inherit", timeout: 300_000,
207
+ });
208
+ console.log(chalk.cyan(' ▶ start "" "Docker Desktop"'));
209
+ await execa("cmd", ["/c", "start", "", "Docker Desktop"], { timeout: 10000 });
210
+ } else {
211
+ console.log(chalk.cyan(" ▶ curl -fsSL https://get.docker.com | sudo sh"));
212
+ await execa("sh", ["-c", "curl -fsSL https://get.docker.com | sudo sh"], {
213
+ stdio: "inherit", timeout: 300_000,
214
+ });
215
+ return;
216
+ }
217
+ // macOS / Windows: wait for daemon after install
218
+ console.log(chalk.dim(" Waiting for Docker daemon to start…"));
219
+ for (let i = 0; i < 30; i++) {
220
+ await new Promise((r) => setTimeout(r, 2000));
221
+ try {
222
+ await execa("docker", ["info"], { timeout: 5000 });
223
+ return;
224
+ } catch {}
225
+ }
226
+ throw new Error("Docker daemon did not start within 60 s");
227
+ });
131
228
  }
132
229
 
230
+ // Docker Compose (bundled with Docker Desktop, but verify)
231
+ const composeVer = await cmdVersion("docker", ["compose", "version"]);
232
+ if (composeVer) ok("Docker Compose", composeVer);
233
+ else fail("Docker Compose not found", "included with Docker Desktop — restart Docker or install the compose plugin");
234
+
133
235
  // Git
134
236
  const gitVer = await cmdVersion("git");
135
237
  if (gitVer) ok("Git available", gitVer);
@@ -141,6 +243,11 @@ export async function runDoctor(opts = {}, registry = null) {
141
243
  if (nodeMajor >= 18) ok(`Node.js v${nodeVer}`, ">=18 required");
142
244
  else fail(`Node.js v${nodeVer}`, "upgrade to >=18");
143
245
 
246
+ // Claude CLI (bundled as a dependency)
247
+ const claudeVer = await cmdVersion("claude");
248
+ if (claudeVer) ok("Claude CLI", claudeVer);
249
+ else fail("Claude CLI not found", "run: npm install (included as a dependency)");
250
+
144
251
  // AWS CLI (optional)
145
252
  const awsVer = await cmdVersion("aws");
146
253
  if (awsVer) ok("AWS CLI", awsVer);
@@ -316,21 +423,42 @@ export async function runDoctor(opts = {}, registry = null) {
316
423
 
317
424
  // Host disk available
318
425
  try {
319
- const { stdout: dfHost } = await execa("df", ["-h", "/"], {
320
- timeout: 5000, reject: false,
321
- });
322
- if (dfHost) {
323
- const lines = dfHost.trim().split("\n");
324
- if (lines.length >= 2) {
325
- const cols = lines[lines.length - 1].split(/\s+/);
326
- const size = cols[1];
327
- const avail = cols[3];
328
- const pct = parseInt(cols[4], 10);
329
- if (pct >= 90) fail(`Host disk: ${avail} free of ${size}`, "critically low — Docker needs room");
330
- else if (pct >= 80) warn(`Host disk: ${avail} free of ${size}`, "getting low");
331
- else ok(`Host disk: ${avail} free of ${size}`);
426
+ let size, avail, pct;
427
+ if (process.platform === "win32") {
428
+ const { stdout: wmicOut } = await execa("wmic", ["logicaldisk", "where", "DeviceID='C:'", "get", "Size,FreeSpace", "/format:csv"], {
429
+ timeout: 5000, reject: false,
430
+ });
431
+ if (wmicOut) {
432
+ const parts = wmicOut.trim().split("\n").pop()?.split(",");
433
+ if (parts?.length >= 3) {
434
+ const free = parseInt(parts[1], 10);
435
+ const total = parseInt(parts[2], 10);
436
+ if (total > 0) {
437
+ size = `${(total / (1024 ** 3)).toFixed(0)} GB`;
438
+ avail = `${(free / (1024 ** 3)).toFixed(0)} GB`;
439
+ pct = Math.round(((total - free) / total) * 100);
440
+ }
441
+ }
442
+ }
443
+ } else {
444
+ const { stdout: dfHost } = await execa("df", ["-h", "/"], {
445
+ timeout: 5000, reject: false,
446
+ });
447
+ if (dfHost) {
448
+ const lines = dfHost.trim().split("\n");
449
+ if (lines.length >= 2) {
450
+ const cols = lines[lines.length - 1].split(/\s+/);
451
+ size = cols[1];
452
+ avail = cols[3];
453
+ pct = parseInt(cols[4], 10);
454
+ }
332
455
  }
333
456
  }
457
+ if (size && avail && pct != null) {
458
+ if (pct >= 90) fail(`Host disk: ${avail} free of ${size}`, "critically low — Docker needs room");
459
+ else if (pct >= 80) warn(`Host disk: ${avail} free of ${size}`, "getting low");
460
+ else ok(`Host disk: ${avail} free of ${size}`);
461
+ }
334
462
  } catch {}
335
463
 
336
464
  // Port conflicts
@@ -386,6 +514,64 @@ export async function runDoctor(opts = {}, registry = null) {
386
514
  }
387
515
  }
388
516
 
517
+ // ── Logs ──────────────────────────────────────────────
518
+ if (dir && dockerVer) {
519
+ header("Logs");
520
+
521
+ try {
522
+ const { stdout: logOut } = await execa("docker", [
523
+ "compose", "logs", "--tail", "50", "--no-color",
524
+ ], { cwd: dir, reject: false, timeout: 30000 });
525
+
526
+ if (logOut?.trim()) {
527
+ const serviceIssues = {};
528
+ const ERROR_RE = /\b(ERROR|FATAL|PANIC|CRITICAL)\b/;
529
+ const CRASH_RE = /\b(OOM|OutOfMemory|out of memory|segmentation fault|segfault)\b/i;
530
+ const CONN_RE = /\b(ECONNREFUSED|ETIMEDOUT|connection refused)\b/i;
531
+
532
+ for (const line of logOut.split("\n")) {
533
+ const sep = line.indexOf(" | ");
534
+ if (sep === -1) continue;
535
+ const service = line.slice(0, sep).trim();
536
+ const msg = line.slice(sep + 3);
537
+
538
+ let level = null;
539
+ if (CRASH_RE.test(msg)) level = "crash";
540
+ else if (ERROR_RE.test(msg)) level = "error";
541
+ else if (CONN_RE.test(msg)) level = "conn";
542
+ else continue;
543
+
544
+ if (!serviceIssues[service]) serviceIssues[service] = { errors: 0, crashes: 0, conn: 0, last: "" };
545
+ const entry = serviceIssues[service];
546
+ if (level === "crash") entry.crashes++;
547
+ else if (level === "conn") entry.conn++;
548
+ else entry.errors++;
549
+ entry.last = msg.trim();
550
+ }
551
+
552
+ const services = Object.keys(serviceIssues);
553
+ if (services.length === 0) {
554
+ ok("No errors in recent logs");
555
+ } else {
556
+ for (const svc of services) {
557
+ const { errors, crashes, conn, last } = serviceIssues[svc];
558
+ const parts = [];
559
+ if (crashes) parts.push(`${crashes} crash`);
560
+ if (errors) parts.push(`${errors} error${errors > 1 ? "s" : ""}`);
561
+ if (conn) parts.push(`${conn} conn issue${conn > 1 ? "s" : ""}`);
562
+ const sample = last.length > 120 ? last.slice(0, 120) + "…" : last;
563
+ if (crashes) fail(svc, `${parts.join(", ")} — ${sample}`);
564
+ else warn(svc, `${parts.join(", ")} — ${sample}`);
565
+ }
566
+ }
567
+ } else {
568
+ ok("No log output", "services may not be running");
569
+ }
570
+ } catch {
571
+ warn("Could not fetch logs", "Docker Compose error");
572
+ }
573
+ }
574
+
389
575
  // ── Images ──────────────────────────────────────────
390
576
  if (dir && dockerVer) {
391
577
  header("Images");
@@ -451,12 +637,12 @@ export async function runDoctor(opts = {}, registry = null) {
451
637
  }
452
638
 
453
639
  // ── Summary ────────────────────────────────────────
454
- console.log(chalk.gray("\n " + "─".repeat(40)));
640
+ console.log(chalk.dim("\n " + "─".repeat(40)));
455
641
  const parts = [];
456
642
  if (passed) parts.push(chalk.green(`${passed} passed`));
457
643
  if (warned) parts.push(chalk.yellow(`${warned} warnings`));
458
644
  if (failed) parts.push(chalk.red(`${failed} failed`));
459
- console.log(" " + parts.join(chalk.gray(" · ")));
645
+ console.log(" " + parts.join(chalk.dim(" · ")));
460
646
 
461
647
  if (failed > 0 && fixes.length > 0) {
462
648
  const shouldFix = opts.fix || await confirm(`\n Fix ${fixes.length} issue(s) automatically?`, true);
@@ -471,7 +657,7 @@ export async function runDoctor(opts = {}, registry = null) {
471
657
  console.log(chalk.red(` ✗ Fix failed: ${err.message}\n`));
472
658
  }
473
659
  }
474
- console.log(chalk.gray(" Run fops doctor again to verify.\n"));
660
+ console.log(chalk.dim(" Run fops doctor again to verify.\n"));
475
661
  } else {
476
662
  console.log("");
477
663
  process.exit(1);
@@ -0,0 +1,197 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import chalk from "chalk";
4
+ import { execa } from "execa";
5
+ import inquirer from "inquirer";
6
+
7
+ /**
8
+ * Canonical feature flags — the complete set known to the platform.
9
+ * Label is a human-readable short description for the toggle UI.
10
+ */
11
+ const KNOWN_FLAGS = {
12
+ MX_FF_STORAGE_EXPLORER_ENABLED: "Storage Explorer",
13
+ MX_FF_USER_MANAGEMENT_ENABLED: "User Management",
14
+ MX_FF_SETTINGS_PAGE_ENABLED: "Settings Page",
15
+ MX_FF_ENCRYPTION_STATUS_DISPLAY: "Encryption Status",
16
+ MX_FF_USER_PAT_ENABLED: "User PAT (Personal Access Tokens)",
17
+ MX_FF_SENTRY_ENABLED: "Sentry Error Tracking",
18
+ MX_FF_EXPLORER_ENABLED: "Data Explorer",
19
+ MX_FF_NEW_PROFILE_ENABLED: "New Profile Page",
20
+ };
21
+
22
+ /**
23
+ * Parse docker-compose.yaml for all MX_FF_* entries.
24
+ * Returns a map: flagName → { value, services: Set<string>, lines: [{ lineNum, original }] }
25
+ */
26
+ function parseComposeFlags(composePath) {
27
+ const content = fs.readFileSync(composePath, "utf8");
28
+ const lines = content.split("\n");
29
+ const flags = {};
30
+
31
+ // Track which service block we're in
32
+ let currentService = null;
33
+
34
+ for (let i = 0; i < lines.length; i++) {
35
+ const line = lines[i];
36
+
37
+ // Service definition: exactly 2-space indent, ends with colon only
38
+ const svcMatch = line.match(/^ ([a-z][\w-]+):\s*$/);
39
+ if (svcMatch) {
40
+ currentService = svcMatch[1];
41
+ }
42
+
43
+ // Match MX_FF_* in both YAML map and list formats
44
+ // Map format: MX_FF_NAME: "value"
45
+ const mapMatch = line.match(/\b(MX_FF_\w+)\s*:\s*"?(true|false)"?/);
46
+ // List format: - MX_FF_NAME=value
47
+ const listMatch = !mapMatch && line.match(/[-]\s*(MX_FF_\w+)\s*=\s*(true|false)/);
48
+
49
+ const match = mapMatch || listMatch;
50
+ if (match) {
51
+ const name = match[1];
52
+ const value = match[2] === "true";
53
+
54
+ if (!flags[name]) {
55
+ flags[name] = { value, services: new Set(), lines: [] };
56
+ }
57
+ flags[name].lines.push({ lineNum: i, original: line });
58
+ if (currentService) flags[name].services.add(currentService);
59
+ // If any occurrence is true, treat the flag as enabled
60
+ if (value) flags[name].value = true;
61
+ }
62
+ }
63
+
64
+ return flags;
65
+ }
66
+
67
+ /**
68
+ * Update docker-compose.yaml by flipping flag values on specific lines.
69
+ */
70
+ function updateComposeFlags(composePath, changes) {
71
+ const content = fs.readFileSync(composePath, "utf8");
72
+ const lines = content.split("\n");
73
+
74
+ for (const { lineNum, newValue } of changes) {
75
+ const line = lines[lineNum];
76
+ lines[lineNum] = line
77
+ .replace(/(MX_FF_\w+\s*:\s*)"?(true|false)"?/, `$1"${newValue}"`)
78
+ .replace(/(MX_FF_\w+=)(true|false)/, `$1${newValue}`);
79
+ }
80
+
81
+ fs.writeFileSync(composePath, lines.join("\n"));
82
+ }
83
+
84
+ /**
85
+ * Interactive feature flag configuration.
86
+ * Reads flags from compose, presents toggle UI, applies changes, restarts services.
87
+ */
88
+ export async function runFeatureFlags(root) {
89
+ const composePath = path.join(root, "docker-compose.yaml");
90
+ if (!fs.existsSync(composePath)) {
91
+ console.log(chalk.red(" No docker-compose.yaml found."));
92
+ return;
93
+ }
94
+
95
+ console.log(chalk.bold.cyan("\n Feature Flags\n"));
96
+
97
+ // Parse current state from compose
98
+ const composeFlags = parseComposeFlags(composePath);
99
+
100
+ // Build the full flag list: compose flags + canonical flags not yet in compose
101
+ const allFlags = {};
102
+ for (const [name, info] of Object.entries(composeFlags)) {
103
+ allFlags[name] = { ...info, inCompose: true };
104
+ }
105
+ for (const name of Object.keys(KNOWN_FLAGS)) {
106
+ if (!allFlags[name]) {
107
+ allFlags[name] = { value: false, services: new Set(), lines: [], inCompose: false };
108
+ }
109
+ }
110
+
111
+ const flagNames = Object.keys(allFlags).sort();
112
+
113
+ // Show current state
114
+ for (const name of flagNames) {
115
+ const flag = allFlags[name];
116
+ const label = KNOWN_FLAGS[name] || name;
117
+ const services = flag.services.size > 0 ? chalk.dim(` (${[...flag.services].join(", ")})`) : "";
118
+ if (flag.value) {
119
+ console.log(chalk.green(` ✓ ${label}`) + services);
120
+ } else {
121
+ console.log(chalk.dim(` · ${label}`) + services);
122
+ }
123
+ }
124
+ console.log("");
125
+
126
+ // Checkbox prompt
127
+ const choices = flagNames.map((name) => ({
128
+ name: KNOWN_FLAGS[name] || name,
129
+ value: name,
130
+ checked: allFlags[name].value,
131
+ }));
132
+
133
+ const { enabled } = await inquirer.prompt([{
134
+ type: "checkbox",
135
+ name: "enabled",
136
+ message: "Toggle feature flags:",
137
+ choices,
138
+ }]);
139
+
140
+ // Calculate changes
141
+ const changes = [];
142
+ const affectedServices = new Set();
143
+
144
+ for (const name of flagNames) {
145
+ const flag = allFlags[name];
146
+ const newValue = enabled.includes(name);
147
+
148
+ if (newValue !== flag.value) {
149
+ if (flag.inCompose) {
150
+ for (const line of flag.lines) {
151
+ changes.push({ lineNum: line.lineNum, newValue: String(newValue) });
152
+ }
153
+ for (const svc of flag.services) affectedServices.add(svc);
154
+ } else if (newValue) {
155
+ console.log(chalk.yellow(` ⚠ ${KNOWN_FLAGS[name] || name} not in docker-compose.yaml — add it to service environments to take effect`));
156
+ }
157
+ }
158
+ }
159
+
160
+ if (changes.length === 0) {
161
+ console.log(chalk.dim("\n No changes.\n"));
162
+ return;
163
+ }
164
+
165
+ // Apply changes to compose file
166
+ updateComposeFlags(composePath, changes);
167
+ console.log(chalk.green(`\n ✓ Updated ${changes.length} flag value(s) in docker-compose.yaml`));
168
+
169
+ if (affectedServices.size === 0) {
170
+ console.log("");
171
+ return;
172
+ }
173
+
174
+ // Restart affected services
175
+ const serviceList = [...affectedServices];
176
+ console.log(chalk.dim(` Affected: ${serviceList.join(", ")}`));
177
+
178
+ const { restart } = await inquirer.prompt([{
179
+ type: "confirm",
180
+ name: "restart",
181
+ message: `Restart ${serviceList.length} service(s)?`,
182
+ default: true,
183
+ }]);
184
+
185
+ if (restart) {
186
+ console.log(chalk.cyan(`\n ▶ docker compose up -d ${serviceList.join(" ")}\n`));
187
+ await execa("docker", ["compose", "up", "-d", ...serviceList], {
188
+ cwd: root,
189
+ stdio: "inherit",
190
+ reject: false,
191
+ timeout: 120_000,
192
+ });
193
+ console.log(chalk.green("\n ✓ Services restarted.\n"));
194
+ } else {
195
+ console.log(chalk.dim("\n Changes saved. Restart manually: docker compose up -d\n"));
196
+ }
197
+ }
@@ -33,5 +33,28 @@ export function createPluginApi(pluginId, registry) {
33
33
  registerHook(event, handler, priority = 0) {
34
34
  registry.hooks.push({ pluginId, event, handler, priority });
35
35
  },
36
+
37
+ registerKnowledgeSource(source) {
38
+ registry.knowledgeSources.push({
39
+ pluginId,
40
+ name: source.name,
41
+ description: source.description || "",
42
+ search: source.search,
43
+ });
44
+ },
45
+
46
+ registerAutoRunPattern(pattern) {
47
+ registry.autoRunPatterns.push({ pluginId, pattern });
48
+ },
49
+
50
+ registerAgent(agent) {
51
+ registry.agents.push({
52
+ pluginId,
53
+ name: agent.name,
54
+ description: agent.description || "",
55
+ systemPrompt: agent.systemPrompt,
56
+ contextMode: agent.contextMode || "full",
57
+ });
58
+ },
36
59
  };
37
60
  }
@@ -0,0 +1,36 @@
1
+ import http from "node:http";
2
+
3
+ /**
4
+ * Built-in Stack API plugin.
5
+ * Registers a doctor check (health ping) and auto-run patterns for curl commands.
6
+ */
7
+ export function register(api) {
8
+ // Doctor check — ping GET /health on localhost:3090
9
+ api.registerDoctorCheck({
10
+ name: "Stack API",
11
+ fn: async (ok, warn) => {
12
+ try {
13
+ const body = await new Promise((resolve, reject) => {
14
+ const req = http.get("http://localhost:3090/health", { timeout: 3000 }, (res) => {
15
+ let data = "";
16
+ res.on("data", (chunk) => { data += chunk; });
17
+ res.on("end", () => {
18
+ if (res.statusCode === 200) resolve(data);
19
+ else reject(new Error(`HTTP ${res.statusCode}`));
20
+ });
21
+ });
22
+ req.on("error", reject);
23
+ req.on("timeout", () => { req.destroy(); reject(new Error("timeout")); });
24
+ });
25
+ ok("Stack API", `healthy — ${body.trim().slice(0, 60)}`);
26
+ } catch {
27
+ warn("Stack API", "not reachable on localhost:3090");
28
+ }
29
+ },
30
+ });
31
+
32
+ // Auto-run: GET curl commands to the stack API execute without confirmation
33
+ api.registerAutoRunPattern("curl http://localhost:3090/");
34
+ api.registerAutoRunPattern("curl -s http://localhost:3090/");
35
+ api.registerAutoRunPattern("curl --silent http://localhost:3090/");
36
+ }
@@ -1,3 +1,4 @@
1
1
  export { loadPlugins } from "./loader.js";
2
2
  export { runHook } from "./hooks.js";
3
3
  export { loadSkills } from "./skills.js";
4
+ export { searchKnowledge } from "./knowledge.js";