@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/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.4",
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";
5
- import { FOUNDATION_SYSTEM_PROMPT, gatherStackContext } from "./context.js";
6
- import { hasClaudeCode, hasClaudeCodeAuth, runViaClaudeCode, streamAssistantReply } from "./llm.js";
4
+ import { PKG } from "../config.js";
5
+ import { renderBanner, promptInput, selectOption } from "../ui/index.js";
6
+ import { FOUNDATION_SYSTEM_PROMPT, gatherStackContext, getFoundationContextBlock } from "./context.js";
7
+ import { hasClaudeCode, hasClaudeCodeAuth, streamAssistantReply } from "./llm.js";
7
8
 
8
9
  /**
9
10
  * Render inline markdown: **bold**, *italic*, `code`
@@ -39,13 +40,13 @@ export function formatResponse(text) {
39
40
  const isShell = /^(bash|sh|shell|zsh)?$/.test(codeLang);
40
41
  const maxLen = Math.max(...codeBlock.map((l) => l.length), 0);
41
42
  const width = Math.max(maxLen + 4, 20);
42
- const top = chalk.gray(" ┌" + "─".repeat(width) + "┐");
43
- const bot = chalk.gray(" └" + "─".repeat(width) + "┘");
43
+ const top = chalk.dim(" ┌" + "─".repeat(width) + "┐");
44
+ const bot = chalk.dim(" └" + "─".repeat(width) + "┘");
44
45
  out.push(top);
45
46
  for (const cl of codeBlock) {
46
47
  const prefix = isShell ? chalk.green("$ ") : " ";
47
48
  const pad = " ".repeat(Math.max(0, width - cl.length - (isShell ? 4 : 2)));
48
- out.push(chalk.gray(" │ ") + prefix + chalk.white(cl) + chalk.gray(pad + " │"));
49
+ out.push(chalk.dim(" │ ") + prefix + chalk.white(cl) + chalk.dim(pad + " │"));
49
50
  }
50
51
  out.push(bot);
51
52
  inCode = false;
@@ -71,7 +72,7 @@ export function formatResponse(text) {
71
72
  // Blockquote
72
73
  if (/^>\s?/.test(line)) {
73
74
  const content = line.replace(/^>\s?/, "");
74
- out.push(chalk.gray.italic(" " + renderInline(content)));
75
+ out.push(chalk.dim.italic(" " + renderInline(content)));
75
76
  continue;
76
77
  }
77
78
 
@@ -123,9 +124,9 @@ async function runShellCommand(root, command, { isLast = true } = {}) {
123
124
  const trimmed = command.replace(/\s+/g, " ").trim();
124
125
  const branch = isLast ? "└─" : "├─";
125
126
  const cont = isLast ? " " : "│ ";
126
- const prefix = ` ${chalk.gray(cont)}`;
127
+ const prefix = ` ${chalk.dim(cont)}`;
127
128
 
128
- console.log(`\n ${chalk.gray(branch)} ${chalk.white(trimmed)} ${chalk.gray("· running")}`);
129
+ console.log(`\n ${chalk.dim(branch)} ${chalk.white(trimmed)} ${chalk.dim("· running")}`);
129
130
 
130
131
  try {
131
132
  // Pipe stdout/stderr so docker/compose use clean non-TTY output
@@ -149,7 +150,7 @@ async function runShellCommand(root, command, { isLast = true } = {}) {
149
150
  const line = raw.includes("\r") ? raw.split("\r").filter(Boolean).pop() : raw;
150
151
  if (!line?.trim()) continue;
151
152
  if (lastWasCR) process.stdout.write("\n");
152
- process.stdout.write(`${prefix}${chalk.gray(line)}`);
153
+ process.stdout.write(`${prefix}${chalk.dim(line)}`);
153
154
  lastWasCR = raw.includes("\r") && !raw.endsWith("\n");
154
155
  if (!lastWasCR) process.stdout.write("\n");
155
156
  }
@@ -174,34 +175,59 @@ async function runShellCommand(root, command, { isLast = true } = {}) {
174
175
  }
175
176
  }
176
177
 
178
+ /**
179
+ * Check if a command matches any plugin-registered auto-run pattern.
180
+ */
181
+ function isAutoRun(command, registry) {
182
+ if (!registry?.autoRunPatterns?.length) return false;
183
+ return registry.autoRunPatterns.some(({ pattern }) => command.trimStart().startsWith(pattern));
184
+ }
185
+
177
186
  /**
178
187
  * Present suggested commands in a single picker.
179
- * Shows each command as a selectable option plus "Run all" / "Skip".
188
+ * Commands matching plugin autoRunPatterns execute immediately.
189
+ * Others show as selectable options plus "Run all" / "Skip".
180
190
  * Returns array of { command, ok, output } for commands that were run.
181
191
  */
182
- export async function askToRunCommand(root, replyText) {
192
+ export async function askToRunCommand(root, replyText, registry) {
183
193
  const suggested = extractSuggestedCommands(replyText);
184
194
  if (suggested.length === 0) return [];
185
195
 
186
- console.log(chalk.cyan("⏺") + chalk.white(" Suggested commands:"));
196
+ // Split into auto-run and interactive commands
197
+ const autoRun = suggested.filter((cmd) => isAutoRun(cmd, registry));
198
+ const interactive = suggested.filter((cmd) => !isAutoRun(cmd, registry));
199
+
200
+ const results = [];
187
201
 
188
- const options = suggested.map((cmd) => ({ label: cmd, value: cmd }));
189
- if (suggested.length > 1) {
190
- options.push({ label: "Run all", value: "__run_all__" });
202
+ // Execute auto-run commands immediately
203
+ if (autoRun.length > 0) {
204
+ for (let i = 0; i < autoRun.length; i++) {
205
+ const isLast = i === autoRun.length - 1 && interactive.length === 0;
206
+ const result = await runShellCommand(root, autoRun[i], { isLast });
207
+ results.push({ command: autoRun[i], ...result });
208
+ }
191
209
  }
192
- options.push({ label: "Skip", value: "__skip__" });
193
210
 
194
- const choice = await selectOption("Run command?", options);
211
+ // Prompt for remaining interactive commands
212
+ if (interactive.length > 0) {
213
+ console.log(chalk.cyan("⏺") + chalk.white(" Suggested commands:"));
195
214
 
196
- if (choice === null || choice === "__skip__") return [];
215
+ const options = interactive.map((cmd) => ({ label: cmd, value: cmd }));
216
+ if (interactive.length > 1) {
217
+ options.push({ label: "Run all", value: "__run_all__" });
218
+ }
219
+ options.push({ label: "Skip", value: "__skip__" });
197
220
 
198
- const toRun = choice === "__run_all__" ? suggested : [choice];
199
- const results = [];
221
+ const choice = await selectOption("Run command?", options);
200
222
 
201
- for (let i = 0; i < toRun.length; i++) {
202
- const isLast = i === toRun.length - 1;
203
- const result = await runShellCommand(root, toRun[i], { isLast });
204
- results.push({ command: toRun[i], ...result });
223
+ if (choice !== null && choice !== "__skip__") {
224
+ const toRun = choice === "__run_all__" ? interactive : [choice];
225
+ for (let i = 0; i < toRun.length; i++) {
226
+ const isLast = i === toRun.length - 1;
227
+ const result = await runShellCommand(root, toRun[i], { isLast });
228
+ results.push({ command: toRun[i], ...result });
229
+ }
230
+ }
205
231
  }
206
232
 
207
233
  return results;
@@ -209,6 +235,7 @@ export async function askToRunCommand(root, replyText) {
209
235
 
210
236
  export async function runAgentSingleTurn(root, message, opts = {}) {
211
237
  const runSuggestions = opts.runSuggestions !== false;
238
+ const registry = opts.registry;
212
239
  const useClaudeCode = hasClaudeCode() && hasClaudeCodeAuth();
213
240
  const anthropicKey = resolveAnthropicApiKey();
214
241
  const openaiKey = resolveOpenAiApiKey();
@@ -218,36 +245,18 @@ export async function runAgentSingleTurn(root, message, opts = {}) {
218
245
  process.exit(1);
219
246
  }
220
247
 
221
- const context = await gatherStackContext(root);
248
+ const context = await gatherStackContext(root, { registry, message });
222
249
  const systemContent = FOUNDATION_SYSTEM_PROMPT + "\n\n" + context;
223
- let replyText = "";
224
-
225
- const spinner = renderSpinner();
226
250
 
251
+ let replyText = "";
227
252
  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
- }
253
+ replyText = await streamAssistantReply(
254
+ root,
255
+ [{ role: "user", content: message }],
256
+ systemContent,
257
+ opts,
258
+ );
249
259
  } catch (err) {
250
- spinner.stop();
251
260
  if (err.code === "MODULE_NOT_FOUND" || err.message?.includes("Cannot find module")) {
252
261
  console.log(chalk.yellow("Install optional deps for agent: npm install @anthropic-ai/sdk openai"));
253
262
  process.exit(1);
@@ -260,33 +269,33 @@ export async function runAgentSingleTurn(root, message, opts = {}) {
260
269
  console.log(chalk.green("Login complete. Run your command again.\n"));
261
270
  process.exit(0);
262
271
  }
263
- console.log(chalk.gray("Get a valid key from console.anthropic.com (we can open the page in your browser on next run).\n"));
272
+ console.log(chalk.dim("Get a valid key from console.anthropic.com (we can open the page in your browser on next run).\n"));
264
273
  process.exit(1);
265
274
  }
266
275
  throw err;
267
276
  }
268
277
 
269
- spinner.stop();
270
278
  console.log("");
271
279
  console.log(formatResponse(replyText));
272
280
  console.log("");
273
281
 
274
282
  if (runSuggestions) {
275
- const cmdResults = await askToRunCommand(root, replyText);
283
+ const cmdResults = await askToRunCommand(root, replyText, registry);
276
284
  if (cmdResults.some((r) => !r.ok)) {
277
285
  console.log(chalk.yellow("\n Some commands failed. Run fops chat for interactive help.\n"));
278
286
  }
279
287
  }
280
288
  }
281
289
 
282
- export async function runAgentInteractive(root) {
290
+ export async function runAgentInteractive(root, opts = {}) {
291
+ const registry = opts.registry;
283
292
  const useClaudeCode = hasClaudeCode() && hasClaudeCodeAuth();
284
293
  const anthropicKey = resolveAnthropicApiKey();
285
294
  const openaiKey = resolveOpenAiApiKey();
286
295
 
287
296
  if (!useClaudeCode && !anthropicKey && !openaiKey) {
288
297
  authHelp();
289
- console.log(chalk.gray(" npm install @anthropic-ai/sdk openai # optional deps\n"));
298
+ console.log(chalk.dim(" npm install @anthropic-ai/sdk openai # optional deps\n"));
290
299
  process.exit(1);
291
300
  }
292
301
 
@@ -301,38 +310,123 @@ export async function runAgentInteractive(root) {
301
310
  }
302
311
  }
303
312
 
304
- const context = await gatherStackContext(root);
305
- const systemContent = FOUNDATION_SYSTEM_PROMPT + "\n\n" + context;
313
+ // Gather base context (without message-specific RAG — that happens per-turn)
314
+ const baseContext = await gatherStackContext(root, { registry });
306
315
  const messages = [];
316
+ let activeAgent = null;
317
+
318
+ // Auto-activate agent if requested (e.g. debug agent on startup failure)
319
+ if (opts.initialAgent) {
320
+ const agents = registry?.agents || [];
321
+ const found = agents.find((a) => a.name === opts.initialAgent);
322
+ if (found) {
323
+ activeAgent = found;
324
+ }
325
+ }
307
326
 
308
327
  // Show banner
309
328
  renderBanner();
310
329
 
311
- // Show quick hints
312
- console.log(chalk.gray(" Try: \"help me set up\" • \"what's running?\" • \"show logs\" • \"run diagnostics\"\n"));
330
+ if (activeAgent) {
331
+ console.log(chalk.cyan(` Agent: ${chalk.bold(activeAgent.name)} ${activeAgent.description}`));
332
+ console.log(chalk.dim(" /exit-agent to return to general mode • /agents to list specialists\n"));
333
+ } else {
334
+ // Show quick hints
335
+ console.log(chalk.dim(" Try: \"help me set up\" • \"what's running?\" • \"/agents\" to list specialists\n"));
336
+ }
313
337
 
314
338
  // Main chat loop
315
339
  while (true) {
316
- const input = await promptInput();
340
+ const statusLabel = activeAgent
341
+ ? `fops v${PKG.version} \u00b7 agent:${activeAgent.name} \u00b7 /exit-agent \u00b7 /exit to quit`
342
+ : `fops v${PKG.version} \u00b7 /agents \u00b7 /exit to quit \u00b7 \u2191\u2193 history`;
343
+ const input = await promptInput(statusLabel);
317
344
 
318
345
  // Exit on null (esc/ctrl+c) or exit command
319
346
  if (input === null || /^\/(exit|quit|q)$/i.test(input)) {
320
- console.log(chalk.gray("\nGoodbye!\n"));
347
+ console.log(chalk.dim("\nGoodbye!\n"));
321
348
  break;
322
349
  }
323
350
 
324
- if (!input.trim()) continue;
351
+ // /agents — list available agents
352
+ if (/^\/agents$/i.test(input.trim())) {
353
+ const agents = registry?.agents || [];
354
+ if (agents.length === 0) {
355
+ console.log(chalk.yellow("\n No agents available.\n"));
356
+ } else {
357
+ console.log(chalk.cyan("\n Available agents:\n"));
358
+ for (const a of agents) {
359
+ const active = activeAgent?.name === a.name ? chalk.green(" (active)") : "";
360
+ console.log(` ${chalk.bold(a.name)}${active} — ${chalk.dim(a.description)}`);
361
+ }
362
+ console.log(chalk.dim(`\n Usage: /agent <name> [task]\n`));
363
+ }
364
+ continue;
365
+ }
366
+
367
+ // /agent <name> [task] — activate an agent
368
+ const agentMatch = input.trim().match(/^\/agent\s+(\S+)(?:\s+(.+))?$/i);
369
+ if (agentMatch) {
370
+ const agentName = agentMatch[1];
371
+ const inlineTask = agentMatch[2] || null;
372
+ const agents = registry?.agents || [];
373
+ const found = agents.find((a) => a.name === agentName);
374
+ if (!found) {
375
+ console.log(chalk.yellow(`\n Unknown agent "${agentName}". Use /agents to see available agents.\n`));
376
+ continue;
377
+ }
378
+ activeAgent = found;
379
+ console.log(chalk.cyan(`\n Activated agent: ${chalk.bold(found.name)} — ${found.description}\n`));
380
+ if (!inlineTask) continue;
381
+ // Fall through with the inline task as user input
382
+ messages.push({ role: "user", content: inlineTask });
383
+ } else {
384
+ // /exit-agent — deactivate agent
385
+ if (/^\/exit-agent$/i.test(input.trim())) {
386
+ if (!activeAgent) {
387
+ console.log(chalk.dim("\n No agent active.\n"));
388
+ } else {
389
+ console.log(chalk.dim(`\n Deactivated agent: ${activeAgent.name}\n`));
390
+ activeAgent = null;
391
+ }
392
+ continue;
393
+ }
394
+
395
+ if (!input.trim()) continue;
325
396
 
326
- messages.push({ role: "user", content: input });
397
+ messages.push({ role: "user", content: input });
398
+ }
327
399
 
328
400
  try {
329
- const replyText = await streamAssistantReply(root, messages, systemContent, {});
401
+ // Re-gather knowledge per turn so RAG is query-specific
402
+ let ragBlock = null;
403
+ if (registry) {
404
+ const { searchKnowledge } = await import("../plugins/knowledge.js");
405
+ ragBlock = await searchKnowledge(registry, messages[messages.length - 1].content);
406
+ }
407
+
408
+ // Build system prompt — use agent's prompt when active
409
+ let systemContent;
410
+ if (activeAgent) {
411
+ const contextBlock = activeAgent.contextMode === "minimal"
412
+ ? getFoundationContextBlock(root)
413
+ : baseContext;
414
+ systemContent = activeAgent.systemPrompt + "\n\n" + contextBlock
415
+ + (ragBlock ? "\n\n" + ragBlock : "");
416
+ } else {
417
+ systemContent = FOUNDATION_SYSTEM_PROMPT + "\n\n" + baseContext
418
+ + (ragBlock ? "\n\n" + ragBlock : "");
419
+ }
420
+
421
+ const replyText = await streamAssistantReply(root, messages, systemContent, {
422
+ replaceSystemPrompt: !!activeAgent,
423
+ });
330
424
  console.log("");
331
425
  console.log(formatResponse(replyText));
332
426
  console.log("");
333
427
  messages.push({ role: "assistant", content: replyText });
334
428
 
335
- const cmdResults = await askToRunCommand(root, replyText);
429
+ const cmdResults = await askToRunCommand(root, replyText, registry);
336
430
  // Feed command outputs back into context so the AI knows what happened
337
431
  if (cmdResults.length > 0) {
338
432
  const summary = cmdResults.map((r) =>
@@ -342,7 +436,6 @@ export async function runAgentInteractive(root) {
342
436
 
343
437
  // Auto-follow-up if any command failed
344
438
  if (cmdResults.some((r) => !r.ok)) {
345
- console.log("");
346
439
  const followUp = await streamAssistantReply(root, messages, systemContent, {});
347
440
  console.log("");
348
441
  console.log(formatResponse(followUp));
@@ -358,7 +451,7 @@ export async function runAgentInteractive(root) {
358
451
  console.log(chalk.red("\nAuthentication failed: invalid or expired API key."));
359
452
  const didLogin = await offerClaudeLogin();
360
453
  if (didLogin) console.log(chalk.green("\nLogin complete. Try your message again.\n"));
361
- else console.log(chalk.gray("Get a valid key from console.anthropic.com\n"));
454
+ else console.log(chalk.dim("Get a valid key from console.anthropic.com\n"));
362
455
  } else {
363
456
  console.log(chalk.red("\nError:"), err.message);
364
457
  }