@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.
- package/README.md +62 -40
- package/package.json +4 -3
- package/src/agent/agent.js +161 -68
- package/src/agent/agents.js +224 -0
- package/src/agent/context.js +287 -96
- package/src/agent/index.js +1 -0
- package/src/agent/llm.js +134 -20
- package/src/auth/coda.js +128 -0
- package/src/auth/index.js +1 -0
- package/src/auth/login.js +13 -13
- package/src/auth/oauth.js +4 -4
- package/src/commands/index.js +94 -21
- package/src/config.js +2 -2
- package/src/doctor.js +208 -22
- package/src/feature-flags.js +197 -0
- package/src/plugins/api.js +23 -0
- package/src/plugins/builtins/stack-api.js +36 -0
- package/src/plugins/index.js +1 -0
- package/src/plugins/knowledge.js +124 -0
- package/src/plugins/loader.js +67 -0
- package/src/plugins/registry.js +3 -0
- package/src/project.js +20 -1
- package/src/setup/aws.js +7 -7
- package/src/setup/setup.js +18 -12
- package/src/setup/wizard.js +86 -15
- package/src/shell.js +2 -2
- package/src/skills/foundation/SKILL.md +200 -66
- package/src/ui/confirm.js +3 -2
- package/src/ui/input.js +31 -34
- package/src/ui/spinner.js +39 -13
- package/src/ui/streaming.js +2 -2
- package/STRUCTURE.md +0 -43
- package/src/agent/agent.test.js +0 -233
- package/src/agent/context.test.js +0 -81
- package/src/agent/llm.test.js +0 -139
- package/src/auth/keychain.test.js +0 -185
- package/src/auth/login.test.js +0 -192
- package/src/auth/oauth.test.js +0 -118
- package/src/auth/resolve.test.js +0 -153
- package/src/config.test.js +0 -70
- package/src/doctor.test.js +0 -134
- package/src/plugins/api.test.js +0 -95
- package/src/plugins/discovery.test.js +0 -92
- package/src/plugins/hooks.test.js +0 -118
- package/src/plugins/manifest.test.js +0 -106
- package/src/plugins/registry.test.js +0 -43
- package/src/plugins/skills.test.js +0 -173
- package/src/project.test.js +0 -196
- package/src/setup/aws.test.js +0 -280
- package/src/shell.test.js +0 -72
- package/src/ui/banner.test.js +0 -97
- 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
|
|
11
|
+
npx @meshxdata/fops doctor
|
|
12
12
|
|
|
13
13
|
# Install globally
|
|
14
|
-
npm install -g
|
|
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/
|
|
20
|
+
cd foundation-compose/operator-cli
|
|
23
21
|
npm install
|
|
24
|
-
npm link # optional: use `
|
|
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/
|
|
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
|
-
| `
|
|
38
|
-
| `
|
|
39
|
-
| `
|
|
40
|
-
| `
|
|
41
|
-
| `
|
|
42
|
-
| `
|
|
43
|
-
| `
|
|
44
|
-
| `
|
|
45
|
-
| `
|
|
46
|
-
| `
|
|
47
|
-
| `
|
|
48
|
-
| `
|
|
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:** `
|
|
53
|
-
- **Single-turn:** `
|
|
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.
|
|
58
|
-
2.
|
|
59
|
-
3. `~/.claude
|
|
60
|
-
4. `~/.claude.json` → `anthropic_api_key`
|
|
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
|
-
|
|
68
|
+
Run `fops login` to authenticate via OAuth browser flow, or `fops login --no-browser` to paste an API key directly.
|
|
63
69
|
|
|
64
|
-
|
|
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
|
|
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
|
-
|
|
73
|
-
|
|
74
|
-
|
|
97
|
+
fops doctor
|
|
98
|
+
fops up
|
|
99
|
+
fops status
|
|
75
100
|
```
|
|
76
101
|
|
|
77
102
|
First-time setup:
|
|
78
103
|
|
|
79
104
|
```bash
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
npx foundation init
|
|
83
|
-
npx foundation up
|
|
105
|
+
fops init
|
|
106
|
+
fops up
|
|
84
107
|
```
|
|
85
108
|
|
|
86
|
-
|
|
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
|
|
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.
|
|
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
|
-
"
|
|
20
|
-
"
|
|
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",
|
package/src/agent/agent.js
CHANGED
|
@@ -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 {
|
|
5
|
-
import {
|
|
6
|
-
import {
|
|
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.
|
|
43
|
-
const bot = chalk.
|
|
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.
|
|
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.
|
|
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.
|
|
127
|
+
const prefix = ` ${chalk.dim(cont)}`;
|
|
127
128
|
|
|
128
|
-
console.log(`\n ${chalk.
|
|
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.
|
|
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
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
189
|
-
if (
|
|
190
|
-
|
|
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
|
-
|
|
211
|
+
// Prompt for remaining interactive commands
|
|
212
|
+
if (interactive.length > 0) {
|
|
213
|
+
console.log(chalk.cyan("⏺") + chalk.white(" Suggested commands:"));
|
|
195
214
|
|
|
196
|
-
|
|
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
|
-
|
|
199
|
-
const results = [];
|
|
221
|
+
const choice = await selectOption("Run command?", options);
|
|
200
222
|
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
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
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
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.
|
|
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.
|
|
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
|
-
|
|
305
|
-
const
|
|
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
|
-
|
|
312
|
-
|
|
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
|
|
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.
|
|
347
|
+
console.log(chalk.dim("\nGoodbye!\n"));
|
|
321
348
|
break;
|
|
322
349
|
}
|
|
323
350
|
|
|
324
|
-
|
|
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
|
-
|
|
397
|
+
messages.push({ role: "user", content: input });
|
|
398
|
+
}
|
|
327
399
|
|
|
328
400
|
try {
|
|
329
|
-
|
|
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.
|
|
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
|
}
|