@meshxdata/fops 0.0.1 → 0.0.3

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/README.md CHANGED
@@ -1,4 +1,4 @@
1
- # Foundation CLI
1
+ # Foundation Operator CLI (`fops`)
2
2
 
3
3
  Install and manage **Foundation** data mesh platforms from the terminal.
4
4
 
@@ -8,90 +8,112 @@ Install and manage **Foundation** data mesh platforms from the terminal.
8
8
 
9
9
  ```bash
10
10
  # Run without installing
11
- npx foundation doctor
11
+ npx @meshxdata/fops doctor
12
12
 
13
13
  # Install globally
14
- npm install -g foundation
15
- # or
16
- pnpm add -g foundation
14
+ npm install -g @meshxdata/fops
17
15
  ```
18
16
 
19
17
  **From repo (development)**
20
18
 
21
19
  ```bash
22
- cd foundation-compose/foundation-cli
20
+ cd foundation-compose/operator-cli
23
21
  npm install
24
- npm link # optional: use `foundation` from anywhere
22
+ npm link # optional: use `fops` from anywhere
25
23
  ```
26
24
 
27
25
  **Shell installer (optional)**
28
26
 
29
27
  ```bash
30
- curl -fsSL https://raw.githubusercontent.com/meshxdata/foundation-compose/main/foundation-cli/install.sh | bash
28
+ curl -fsSL https://raw.githubusercontent.com/meshxdata/foundation-compose/main/operator-cli/install.sh | bash
31
29
  ```
32
30
 
33
31
  ## Commands
34
32
 
35
33
  | Command | Description |
36
34
  |---------|-------------|
37
- | `foundation setup` | Full automated setup (`.env`, netrc reminder, submodules; use `--download` to pull images). Replaces `setup.sh`. |
38
- | `foundation init` | Initialize a Foundation project (submodules, `.env`); same as `setup` with default options |
39
- | `foundation up` | Start all services (`make start`) |
40
- | `foundation down` | Stop services; use `--clean` to remove volumes |
41
- | `foundation status` | Show service status |
42
- | `foundation logs [service]` | Stream logs (all or one service) |
43
- | `foundation doctor` | Check Docker, git, `.env`, project layout; use `--fix` to apply fixes |
44
- | `foundation config` | Launch interactive configuration |
45
- | `foundation bootstrap` | Create demo data mesh |
46
- | `foundation test` | Run health checks |
47
- | `foundation agent` | AI assistant for the stack. Interactive or single-turn with `-m "message"`. |
48
- | `foundation chat` | Same as `foundation agent` (interactive). |
35
+ | `fops login` | Authenticate with Claude (default) or other services |
36
+ | `fops login coda` | Authenticate with Coda (guided API token setup) |
37
+ | `fops setup` | Full automated setup (`.env`, netrc reminder, submodules; use `--download` to pull images) |
38
+ | `fops init` | Initialize a Foundation project. Interactive wizard when not in a project root. |
39
+ | `fops up` | Start all services (`make start`); launches interactive AI assistant after startup |
40
+ | `fops down` | Stop services; use `--clean` to remove volumes |
41
+ | `fops status` | Show service status |
42
+ | `fops logs [service]` | Stream logs (all or one service) |
43
+ | `fops doctor` | Check Docker, git, `.env`, AWS/ECR, ports, services; use `--fix` to apply fixes |
44
+ | `fops config` | Launch interactive configuration |
45
+ | `fops bootstrap` | Create demo data mesh |
46
+ | `fops test` | Run health checks |
47
+ | `fops agent` | AI assistant for the stack. Interactive or single-turn with `-m "message"`. |
48
+ | `fops chat` | Interactive AI assistant (same as `fops agent`) |
49
+ | `fops skill list` | List available agent skills (built-in and plugin-provided) |
50
+ | `fops plugin list` | List installed plugins |
51
+ | `fops plugin install <source>` | Install a plugin from a local directory |
52
+ | `fops plugin remove <id>` | Remove an installed plugin |
49
53
 
50
54
  ### AI Assistant (agent / chat)
51
55
 
52
- - **Interactive:** `foundation agent` or `foundation chat` — in-CLI chat (no Claude Code app). Uses Anthropic or OpenAI APIs with streaming.
53
- - **Single-turn:** `foundation agent -m "why are my containers failing?"` — one question, one reply; optional run suggested command?” prompt.
56
+ - **Interactive:** `fops agent` or `fops chat` — in-CLI chat with streaming responses and command execution.
57
+ - **Single-turn:** `fops agent -m "why are my containers failing?"` — one question, one reply; optional "run suggested command?" prompt.
58
+ - **Model override:** `fops agent --model claude-sonnet-4-20250514`
54
59
 
55
60
  **Auth** (checked in order):
56
61
 
57
- 1. `ANTHROPIC_API_KEY` or `OPENAI_API_KEY` environment variable
58
- 2. `~/.claude/settings.json``apiKeyHelper` (script that prints the API key)
59
- 3. `~/.claude/.credentials.json` → `anthropic_api_key` or `apiKey`
60
- 4. `~/.claude.json` → `anthropic_api_key` or `apiKey`
62
+ 1. Claude Code CLI OAuth (if `claude` is installed and authenticated)
63
+ 2. `ANTHROPIC_API_KEY` environment variable
64
+ 3. `~/.claude/settings.json` → `apiKeyHelper` (script that prints the API key)
65
+ 4. `~/.claude/.credentials.json` or `~/.claude.json` → `anthropic_api_key` / `apiKey`
66
+ 5. `OPENAI_API_KEY` environment variable (fallback; uses OpenAI-compatible models)
61
67
 
62
- On macOS, Claude Code stores keys in Keychain; use `apiKeyHelper` in `~/.claude/settings.json` pointing to a script that reads the key (e.g. from keychain) and prints it, or put a key in `~/.claude/.credentials.json`.
68
+ Run `fops login` to authenticate via OAuth browser flow, or `fops login --no-browser` to paste an API key directly.
63
69
 
64
- Install optional deps: `npm install @anthropic-ai/sdk openai`.
70
+ **Coda** Run `fops login coda` to set up your Coda API token. The wizard opens your browser to coda.io/account and guides you through generating and pasting the token. Token is validated via the Coda API and saved to `~/.fops.json`. You can also set `CODA_API_TOKEN` as an env var.
71
+
72
+ ### Doctor
73
+
74
+ Checks prerequisites (Docker, Git, Node.js, AWS CLI), AWS/ECR access, project structure, Docker resources, port conflicts, running services, and image freshness. Plugins can register additional checks.
75
+
76
+ On `--fix`, doctor can automatically:
77
+ - Install Docker (macOS via Homebrew, Windows via winget, Linux via get.docker.com)
78
+ - Start the Docker daemon
79
+ - Fix AWS SSO sessions and ECR authentication
80
+ - Create missing `.env` from `.env.example`
81
+ - Run `fops init` for missing projects
82
+ - Rebuild stale images
83
+
84
+ ### Plugins
85
+
86
+ Plugins extend the CLI with custom commands, doctor checks, lifecycle hooks, and agent skills.
87
+
88
+ - **Global plugins:** `~/.fops/plugins/<id>/` (each with `fops.plugin.json`)
89
+ - **NPM plugins:** packages named `fops-plugin-*` or `@scope/fops-plugin-*`
65
90
 
66
91
  ## Usage
67
92
 
68
- Run from a **foundation-compose** clone (or any directory that contains `docker-compose.yaml` and `Makefile`). You can also set `FOUNDATION_ROOT` to point to your repo root.
93
+ Run from a **foundation-compose** clone (or any directory containing `docker-compose.yaml` and `Makefile`). You can also set `FOUNDATION_ROOT` to point to your repo root.
69
94
 
70
95
  ```bash
71
96
  cd /path/to/foundation-compose
72
- foundation doctor
73
- foundation up
74
- foundation status
97
+ fops doctor
98
+ fops up
99
+ fops status
75
100
  ```
76
101
 
77
102
  First-time setup:
78
103
 
79
104
  ```bash
80
- git clone https://github.com/meshxdata/foundation-compose
81
- cd foundation-compose
82
- npx foundation init
83
- npx foundation up
105
+ fops init
106
+ fops up
84
107
  ```
85
108
 
86
- ## Project structure
87
-
88
- The CLI is split into modules under `src/` for long-term maintainability. See [STRUCTURE.md](STRUCTURE.md) for layout and how to add commands.
109
+ The init wizard will prompt to clone the repository if no project is found.
89
110
 
90
111
  ## Requirements
91
112
 
92
113
  - Node.js >= 18
93
114
  - Docker (Docker Compose)
94
- - Git (for `foundation init` submodules)
115
+ - Git (for submodules)
116
+ - Claude CLI (bundled — installed automatically via `npm install`)
95
117
 
96
118
  ## License
97
119
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@meshxdata/fops",
3
- "version": "0.0.1",
3
+ "version": "0.0.3",
4
4
  "description": "CLI to install and manage Foundation data mesh platforms",
5
5
  "keywords": [
6
6
  "foundation",
@@ -16,8 +16,8 @@
16
16
  "files": [
17
17
  "foundation.mjs",
18
18
  "src/",
19
- "README.md",
20
- "STRUCTURE.md"
19
+ "!src/**/*.test.js",
20
+ "README.md"
21
21
  ],
22
22
  "scripts": {
23
23
  "start": "node foundation.mjs",
@@ -25,6 +25,7 @@
25
25
  "test:watch": "vitest"
26
26
  },
27
27
  "dependencies": {
28
+ "@anthropic-ai/claude-code": "^1.0.0",
28
29
  "boxen": "^8.0.1",
29
30
  "chalk": "^5.3.0",
30
31
  "commander": "^12.0.0",
@@ -1,9 +1,10 @@
1
1
  import chalk from "chalk";
2
2
  import { execa } from "execa";
3
3
  import { resolveAnthropicApiKey, resolveOpenAiApiKey, authHelp, offerClaudeLogin } from "../auth/index.js";
4
- import { renderSpinner, renderBanner, promptInput, selectOption } from "../ui/index.js";
4
+ import { PKG } from "../config.js";
5
+ import { renderBanner, promptInput, selectOption } from "../ui/index.js";
5
6
  import { FOUNDATION_SYSTEM_PROMPT, gatherStackContext } from "./context.js";
6
- import { hasClaudeCode, hasClaudeCodeAuth, runViaClaudeCode, streamAssistantReply } from "./llm.js";
7
+ import { hasClaudeCode, hasClaudeCodeAuth, streamAssistantReply } from "./llm.js";
7
8
 
8
9
  /**
9
10
  * Render inline markdown: **bold**, *italic*, `code`
@@ -209,6 +210,7 @@ export async function askToRunCommand(root, replyText) {
209
210
 
210
211
  export async function runAgentSingleTurn(root, message, opts = {}) {
211
212
  const runSuggestions = opts.runSuggestions !== false;
213
+ const registry = opts.registry;
212
214
  const useClaudeCode = hasClaudeCode() && hasClaudeCodeAuth();
213
215
  const anthropicKey = resolveAnthropicApiKey();
214
216
  const openaiKey = resolveOpenAiApiKey();
@@ -218,36 +220,18 @@ export async function runAgentSingleTurn(root, message, opts = {}) {
218
220
  process.exit(1);
219
221
  }
220
222
 
221
- const context = await gatherStackContext(root);
223
+ const context = await gatherStackContext(root, { registry, message });
222
224
  const systemContent = FOUNDATION_SYSTEM_PROMPT + "\n\n" + context;
223
- let replyText = "";
224
-
225
- const spinner = renderSpinner();
226
225
 
226
+ let replyText = "";
227
227
  try {
228
- if (useClaudeCode) {
229
- // Use Claude Code CLI with OAuth auth
230
- replyText = await runViaClaudeCode(message, systemContent);
231
- } else if (anthropicKey) {
232
- const { default: Anthropic } = await import("@anthropic-ai/sdk");
233
- const client = new Anthropic({ apiKey: anthropicKey });
234
- const response = await client.messages.create({
235
- model: opts.model || "claude-sonnet-4-20250514",
236
- max_tokens: 2048, system: systemContent,
237
- messages: [{ role: "user", content: message }],
238
- });
239
- replyText = response.content.filter((b) => b.type === "text").map((b) => b.text).join("\n");
240
- } else if (openaiKey) {
241
- const OpenAI = (await import("openai")).default;
242
- const client = new OpenAI({ apiKey: openaiKey });
243
- const response = await client.chat.completions.create({
244
- model: opts.model || "gpt-4o-mini", max_tokens: 2048,
245
- messages: [{ role: "system", content: systemContent }, { role: "user", content: message }],
246
- });
247
- replyText = response.choices?.[0]?.message?.content ?? "";
248
- }
228
+ replyText = await streamAssistantReply(
229
+ root,
230
+ [{ role: "user", content: message }],
231
+ systemContent,
232
+ opts,
233
+ );
249
234
  } catch (err) {
250
- spinner.stop();
251
235
  if (err.code === "MODULE_NOT_FOUND" || err.message?.includes("Cannot find module")) {
252
236
  console.log(chalk.yellow("Install optional deps for agent: npm install @anthropic-ai/sdk openai"));
253
237
  process.exit(1);
@@ -266,7 +250,6 @@ export async function runAgentSingleTurn(root, message, opts = {}) {
266
250
  throw err;
267
251
  }
268
252
 
269
- spinner.stop();
270
253
  console.log("");
271
254
  console.log(formatResponse(replyText));
272
255
  console.log("");
@@ -279,7 +262,8 @@ export async function runAgentSingleTurn(root, message, opts = {}) {
279
262
  }
280
263
  }
281
264
 
282
- export async function runAgentInteractive(root) {
265
+ export async function runAgentInteractive(root, opts = {}) {
266
+ const registry = opts.registry;
283
267
  const useClaudeCode = hasClaudeCode() && hasClaudeCodeAuth();
284
268
  const anthropicKey = resolveAnthropicApiKey();
285
269
  const openaiKey = resolveOpenAiApiKey();
@@ -301,8 +285,8 @@ export async function runAgentInteractive(root) {
301
285
  }
302
286
  }
303
287
 
304
- const context = await gatherStackContext(root);
305
- const systemContent = FOUNDATION_SYSTEM_PROMPT + "\n\n" + context;
288
+ // Gather base context (without message-specific RAG — that happens per-turn)
289
+ const baseContext = await gatherStackContext(root, { registry });
306
290
  const messages = [];
307
291
 
308
292
  // Show banner
@@ -313,7 +297,7 @@ export async function runAgentInteractive(root) {
313
297
 
314
298
  // Main chat loop
315
299
  while (true) {
316
- const input = await promptInput();
300
+ const input = await promptInput(`fops v${PKG.version} \u00b7 /exit to quit \u00b7 \u2191\u2193 history`);
317
301
 
318
302
  // Exit on null (esc/ctrl+c) or exit command
319
303
  if (input === null || /^\/(exit|quit|q)$/i.test(input)) {
@@ -326,6 +310,15 @@ export async function runAgentInteractive(root) {
326
310
  messages.push({ role: "user", content: input });
327
311
 
328
312
  try {
313
+ // Re-gather knowledge per turn so RAG is query-specific
314
+ let ragBlock = null;
315
+ if (registry) {
316
+ const { searchKnowledge } = await import("../plugins/knowledge.js");
317
+ ragBlock = await searchKnowledge(registry, input);
318
+ }
319
+ const systemContent = FOUNDATION_SYSTEM_PROMPT + "\n\n" + baseContext
320
+ + (ragBlock ? "\n\n" + ragBlock : "");
321
+
329
322
  const replyText = await streamAssistantReply(root, messages, systemContent, {});
330
323
  console.log("");
331
324
  console.log(formatResponse(replyText));
@@ -342,7 +335,6 @@ export async function runAgentInteractive(root) {
342
335
 
343
336
  // Auto-follow-up if any command failed
344
337
  if (cmdResults.some((r) => !r.ok)) {
345
- console.log("");
346
338
  const followUp = await streamAssistantReply(root, messages, systemContent, {});
347
339
  console.log("");
348
340
  console.log(formatResponse(followUp));
@@ -1,7 +1,7 @@
1
1
  import fs from "node:fs";
2
2
  import path from "node:path";
3
3
  import { execa } from "execa";
4
- import { loadSkills } from "../plugins/index.js";
4
+ import { loadSkills, searchKnowledge } from "../plugins/index.js";
5
5
 
6
6
  export const FOUNDATION_SYSTEM_PROMPT = `You are FOPS — the Foundation Operator. Think of yourself as the system admin who actually knows what they're doing. You're direct, no-BS, slightly irreverent. You don't sugarcoat problems — you diagnose and fix them. Channel the energy of someone who lives in the terminal and sees the matrix in docker logs.
7
7
 
@@ -11,44 +11,14 @@ export const FOUNDATION_SYSTEM_PROMPT = `You are FOPS — the Foundation Operato
11
11
  - When something is broken, say what's broken and how to fix it. No preambles.
12
12
  - Treat the user like a peer, not a customer.
13
13
 
14
- ## Capabilities
15
- - **Setup & Init**: Prerequisites, environment config, first-run setup
16
- - **Operations**: Start/stop services, status, logs, diagnostics
17
- - **Debugging**: Troubleshoot issues, analyze logs, suggest fixes
18
- - **Security**: Validate configs, check credentials safely (never log secrets)
19
-
20
14
  ## Commands
21
15
  When suggesting commands, ALWAYS use \`fops\` commands, not raw \`make\` or \`git clone\`. Output each in its own fenced block.
22
16
 
23
- **Always suggest 2–3 commands** so the user can pick. For example, if someone asks "what's running?", suggest both a status check and a diagnostic:
24
- \`\`\`bash
25
- fops status
26
- \`\`\`
27
- \`\`\`bash
28
- fops doctor
29
- \`\`\`
30
-
31
- If a single action is needed, pair it with a follow-up (e.g. restart + logs, doctor + up).
32
-
33
- ## Available fops Commands
34
- - fops init — clone repos, bootstrap environment
35
- - fops up / fops down — start/stop the stack
36
- - fops restart — restart all or specific services
37
- - fops status — show running containers
38
- - fops logs [service] — tail logs
39
- - fops doctor — run diagnostics
40
- - fops login — authenticate with AWS/ECR
41
- - fops agent / fops chat — talk to me
42
-
43
- ## Services & Ports
44
- Backend:9001, Frontend:3002, Storage:9002, Trino:8081, OPA:8181, Kafka:9092, Postgres:5432, Hive:9083, Vault:18201
45
-
46
- ## Setup Checklist (for new users)
47
- 1. Install prerequisites: git, docker, node >= 18, aws cli (optional)
48
- 2. \`npm install -g @meshxdata/fops\`
49
- 3. \`fops init\` — clones repos, sets up .env
50
- 4. \`fops up\` — boots the stack
51
- 5. \`fops doctor\` — verifies everything is healthy
17
+ **Always suggest 2–3 commands** so the user can pick. Pair a primary action with a follow-up (e.g. restart + logs, doctor + up).
18
+
19
+ ## Accuracy Rules
20
+ - ALWAYS check the container status context carefully. If ANY container is exited, unhealthy, or failed, report it — never claim "all healthy" when failures exist.
21
+ - When containers have failed, lead with the failures and suggest diagnostics.
52
22
 
53
23
  ## Security Rules
54
24
  - Never output API keys, passwords, or tokens in responses
@@ -60,84 +30,171 @@ export function getFoundationContextBlock(root) {
60
30
  return `Project root: ${root}. Commands run in this directory.`;
61
31
  }
62
32
 
63
- export async function gatherStackContext(root) {
64
- const parts = [getFoundationContextBlock(root)];
65
-
66
- // Check Docker status (only if we have a project root)
67
- if (root) {
68
- try {
69
- const { stdout: psOut } = await execa("docker", ["compose", "ps", "--format", "json"], { cwd: root, reject: false, timeout: 5000 });
70
- if (psOut && psOut.trim()) {
71
- const lines = psOut.trim().split("\n").filter(Boolean);
72
- const services = lines.map((line) => {
73
- try {
74
- const o = JSON.parse(line);
75
- return `${o.Name || o.name || "?"}: ${o.State || o.Status || "?"}`;
76
- } catch {
77
- return null;
78
- }
79
- }).filter(Boolean);
80
- if (services.length) parts.push("Running containers:\n" + services.join("\n"));
81
- else parts.push("No containers running.");
33
+ async function gatherDockerStatus(root) {
34
+ try {
35
+ const { stdout: psOut } = await execa("docker", ["compose", "ps", "--format", "json"], { cwd: root, reject: false, timeout: 5000 });
36
+ if (psOut && psOut.trim()) {
37
+ const lines = psOut.trim().split("\n").filter(Boolean);
38
+ const parsed = lines.map((line) => {
39
+ try { return JSON.parse(line); } catch { return null; }
40
+ }).filter(Boolean);
41
+
42
+ if (!parsed.length) return "No containers running.";
43
+
44
+ let healthy = 0, unhealthy = 0, exited = 0, running = 0;
45
+ const services = parsed.map((o) => {
46
+ const name = o.Name || o.name || o.Service || "?";
47
+ const state = (o.State || "").toLowerCase();
48
+ const status = o.Status || "";
49
+ const health = (o.Health || "").toLowerCase();
50
+ const exitCode = o.ExitCode ?? "";
51
+
52
+ if (state === "exited" || state === "dead") {
53
+ exited++;
54
+ return `${name}: EXITED (code ${exitCode}) — ${status}`;
55
+ }
56
+ if (health === "unhealthy") {
57
+ unhealthy++;
58
+ return `${name}: UNHEALTHY — ${status}`;
59
+ }
60
+ if (state === "running" && (health === "healthy" || !health)) {
61
+ healthy++;
62
+ running++;
63
+ return `${name}: running ${health ? "(healthy)" : ""} — ${status}`;
64
+ }
65
+ running++;
66
+ return `${name}: ${state} ${health ? `(${health})` : ""} — ${status}`;
67
+ });
68
+
69
+ const summary = [];
70
+ if (running) summary.push(`${running} running`);
71
+ if (healthy) summary.push(`${healthy} healthy`);
72
+ if (unhealthy) summary.push(`${unhealthy} UNHEALTHY`);
73
+ if (exited) summary.push(`${exited} EXITED/FAILED`);
74
+
75
+ let header = `Container summary: ${parsed.length} total — ${summary.join(", ")}`;
76
+ if (unhealthy || exited) {
77
+ header += "\n⚠ ATTENTION: Some containers are failing. Diagnose and report the failures.";
82
78
  }
83
- } catch {
84
- parts.push("Docker: not available or not running.");
79
+
80
+ return header + "\n\nContainer details:\n" + services.join("\n");
85
81
  }
82
+ } catch {
83
+ return "Docker: not available or not running.";
86
84
  }
85
+ return null;
86
+ }
87
87
 
88
- // Check image ages
89
- if (root) {
90
- try {
91
- const { stdout: imgOut } = await execa("docker", ["compose", "images", "--format", "json"], { cwd: root, reject: false, timeout: 5000 });
92
- if (imgOut?.trim()) {
93
- const ages = [];
94
- for (const line of imgOut.trim().split("\n").filter(Boolean)) {
95
- try {
96
- const img = JSON.parse(line);
97
- const id = img.ID || img.id || "";
98
- const repo = img.Repository || img.repository || "";
99
- const tag = img.Tag || img.tag || "";
100
- if (!id) continue;
101
- const { stdout: created } = await execa("docker", ["image", "inspect", id, "--format", "{{.Created}}"], { reject: false, timeout: 3000 });
102
- if (created?.trim()) {
103
- const days = Math.floor((Date.now() - new Date(created.trim()).getTime()) / 86400000);
104
- const name = `${repo}:${tag}`.replace(/^:|:$/g, "") || id.slice(0, 12);
105
- ages.push(`${name}: ${days}d old`);
106
- }
107
- } catch {}
108
- }
109
- if (ages.length) parts.push("Image ages:\n" + ages.join("\n"));
110
- }
111
- } catch {}
112
- }
88
+ async function gatherImageAges(root) {
89
+ try {
90
+ const { stdout: imgOut } = await execa("docker", ["compose", "images", "--format", "json"], { cwd: root, reject: false, timeout: 5000 });
91
+ if (imgOut?.trim()) {
92
+ const images = imgOut.trim().split("\n").filter(Boolean).map((line) => {
93
+ try { return JSON.parse(line); } catch { return null; }
94
+ }).filter(Boolean);
95
+
96
+ // Inspect all images in parallel
97
+ const ageResults = await Promise.all(images.map(async (img) => {
98
+ try {
99
+ const id = img.ID || img.id || "";
100
+ const repo = img.Repository || img.repository || "";
101
+ const tag = img.Tag || img.tag || "";
102
+ if (!id) return null;
103
+ const { stdout: created } = await execa("docker", ["image", "inspect", id, "--format", "{{.Created}}"], { reject: false, timeout: 3000 });
104
+ if (created?.trim()) {
105
+ const days = Math.floor((Date.now() - new Date(created.trim()).getTime()) / 86400000);
106
+ const name = `${repo}:${tag}`.replace(/^:|:$/g, "") || id.slice(0, 12);
107
+ return `${name}: ${days}d old`;
108
+ }
109
+ } catch {}
110
+ return null;
111
+ }));
113
112
 
114
- // Check prerequisites (each wrapped individually so one failure doesn't kill the rest)
115
- const prereqs = [];
116
- try { await execa("git", ["--version"], { reject: false, timeout: 3000 }); prereqs.push("git: ✓"); } catch { prereqs.push("git: ✗"); }
117
- try { await execa("docker", ["info"], { reject: false, timeout: 5000 }); prereqs.push("docker: ✓"); } catch { prereqs.push("docker: ✗"); }
118
- try { await execa("aws", ["--version"], { reject: false, timeout: 3000 }); prereqs.push("aws-cli: ✓"); } catch { prereqs.push("aws-cli: ✗ (optional)"); }
119
- parts.push("Prerequisites: " + prereqs.join(", "));
113
+ const ages = ageResults.filter(Boolean);
114
+ if (ages.length) return "Image ages:\n" + ages.join("\n");
115
+ }
116
+ } catch {}
117
+ return null;
118
+ }
120
119
 
121
- // Check for .env file
122
- if (root) {
123
- const envPath = path.join(root, ".env");
124
- const envExamplePath = path.join(root, ".env.example");
125
- if (fs.existsSync(envPath)) {
126
- parts.push(".env: configured");
127
- } else if (fs.existsSync(envExamplePath)) {
128
- parts.push(".env: not configured (run 'fops setup' or 'cp .env.example .env')");
120
+ async function gatherPrereqs() {
121
+ const checks = await Promise.all([
122
+ execa("git", ["--version"], { reject: false, timeout: 3000 }).then(() => "git: ✓").catch(() => "git: ✗ (REQUIRED)"),
123
+ execa("docker", ["info"], { reject: false, timeout: 5000 }).then(() => "docker: ✓").catch(() => "docker: ✗ (REQUIRED)"),
124
+ execa("aws", ["--version"], { reject: false, timeout: 3000 }).then(() => "aws-cli: ✓").catch(() => "aws-cli: ✗ (REQUIRED)"),
125
+ ]);
126
+
127
+ // Check git netrc for GitHub auth
128
+ const homedir = (await import("node:os")).default.homedir();
129
+ const netrcPath = (await import("node:path")).default.join(homedir, ".netrc");
130
+ let netrc = "git-netrc: ✗ (REQUIRED — needed for private repo access)";
131
+ try {
132
+ const netrcContent = (await import("node:fs")).default.readFileSync(netrcPath, "utf8");
133
+ if (netrcContent.includes("github.com")) {
134
+ netrc = "git-netrc: ✓ (github.com configured)";
135
+ } else {
136
+ netrc = "git-netrc: ✗ (no github.com entry — REQUIRED)";
129
137
  }
138
+ } catch {}
139
+
140
+ checks.push(netrc);
141
+ return "Prerequisites: " + checks.join(", ");
142
+ }
143
+
144
+ function checkEnvFile(root) {
145
+ const envPath = path.join(root, ".env");
146
+ const envExamplePath = path.join(root, ".env.example");
147
+ if (fs.existsSync(envPath)) {
148
+ return ".env: configured";
149
+ } else if (fs.existsSync(envExamplePath)) {
150
+ return ".env: not configured (run 'fops setup' or 'cp .env.example .env')";
130
151
  }
152
+ return null;
153
+ }
131
154
 
132
- // Inject plugin skills into context
155
+ async function gatherSkills() {
133
156
  try {
134
157
  const skills = await loadSkills();
135
158
  if (skills.length) {
136
- parts.push("## Additional Skills\n" + skills.map((s) => s.content).join("\n\n"));
159
+ return "## Additional Skills\n" + skills.map((s) => s.content).join("\n\n");
137
160
  }
138
161
  } catch {
139
162
  // skip if skill loading fails
140
163
  }
164
+ return null;
165
+ }
166
+
167
+ export async function gatherStackContext(root, { registry, message } = {}) {
168
+ const parts = [getFoundationContextBlock(root)];
169
+
170
+ if (root) {
171
+ // Run all independent checks in parallel
172
+ const [dockerStatus, imageAges, prereqs, envInfo, skills, knowledge] = await Promise.all([
173
+ gatherDockerStatus(root),
174
+ gatherImageAges(root),
175
+ gatherPrereqs(),
176
+ Promise.resolve(checkEnvFile(root)),
177
+ gatherSkills(),
178
+ registry && message ? searchKnowledge(registry, message) : Promise.resolve(null),
179
+ ]);
180
+
181
+ if (dockerStatus) parts.push(dockerStatus);
182
+ if (imageAges) parts.push(imageAges);
183
+ parts.push(prereqs);
184
+ if (envInfo) parts.push(envInfo);
185
+ if (skills) parts.push(skills);
186
+ if (knowledge) parts.push(knowledge);
187
+ } else {
188
+ // No root — still check prereqs, skills, and knowledge
189
+ const [prereqs, skills, knowledge] = await Promise.all([
190
+ gatherPrereqs(),
191
+ gatherSkills(),
192
+ registry && message ? searchKnowledge(registry, message) : Promise.resolve(null),
193
+ ]);
194
+ parts.push(prereqs);
195
+ if (skills) parts.push(skills);
196
+ if (knowledge) parts.push(knowledge);
197
+ }
141
198
 
142
199
  return parts.join("\n\n");
143
200
  }