@meshxdata/fops 0.0.1
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 +98 -0
- package/STRUCTURE.md +43 -0
- package/foundation.mjs +16 -0
- package/package.json +52 -0
- package/src/agent/agent.js +367 -0
- package/src/agent/agent.test.js +233 -0
- package/src/agent/context.js +143 -0
- package/src/agent/context.test.js +81 -0
- package/src/agent/index.js +2 -0
- package/src/agent/llm.js +127 -0
- package/src/agent/llm.test.js +139 -0
- package/src/auth/index.js +4 -0
- package/src/auth/keychain.js +58 -0
- package/src/auth/keychain.test.js +185 -0
- package/src/auth/login.js +421 -0
- package/src/auth/login.test.js +192 -0
- package/src/auth/oauth.js +203 -0
- package/src/auth/oauth.test.js +118 -0
- package/src/auth/resolve.js +78 -0
- package/src/auth/resolve.test.js +153 -0
- package/src/commands/index.js +268 -0
- package/src/config.js +24 -0
- package/src/config.test.js +70 -0
- package/src/doctor.js +487 -0
- package/src/doctor.test.js +134 -0
- package/src/plugins/api.js +37 -0
- package/src/plugins/api.test.js +95 -0
- package/src/plugins/discovery.js +78 -0
- package/src/plugins/discovery.test.js +92 -0
- package/src/plugins/hooks.js +13 -0
- package/src/plugins/hooks.test.js +118 -0
- package/src/plugins/index.js +3 -0
- package/src/plugins/loader.js +110 -0
- package/src/plugins/manifest.js +26 -0
- package/src/plugins/manifest.test.js +106 -0
- package/src/plugins/registry.js +14 -0
- package/src/plugins/registry.test.js +43 -0
- package/src/plugins/skills.js +126 -0
- package/src/plugins/skills.test.js +173 -0
- package/src/project.js +61 -0
- package/src/project.test.js +196 -0
- package/src/setup/aws.js +369 -0
- package/src/setup/aws.test.js +280 -0
- package/src/setup/index.js +3 -0
- package/src/setup/setup.js +161 -0
- package/src/setup/wizard.js +119 -0
- package/src/shell.js +9 -0
- package/src/shell.test.js +72 -0
- package/src/skills/foundation/SKILL.md +107 -0
- package/src/ui/banner.js +56 -0
- package/src/ui/banner.test.js +97 -0
- package/src/ui/confirm.js +97 -0
- package/src/ui/index.js +5 -0
- package/src/ui/input.js +199 -0
- package/src/ui/spinner.js +170 -0
- package/src/ui/spinner.test.js +29 -0
- package/src/ui/streaming.js +106 -0
package/README.md
ADDED
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
# Foundation CLI
|
|
2
|
+
|
|
3
|
+
Install and manage **Foundation** data mesh platforms from the terminal.
|
|
4
|
+
|
|
5
|
+
## Install
|
|
6
|
+
|
|
7
|
+
**npm / npx (recommended)**
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
# Run without installing
|
|
11
|
+
npx foundation doctor
|
|
12
|
+
|
|
13
|
+
# Install globally
|
|
14
|
+
npm install -g foundation
|
|
15
|
+
# or
|
|
16
|
+
pnpm add -g foundation
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
**From repo (development)**
|
|
20
|
+
|
|
21
|
+
```bash
|
|
22
|
+
cd foundation-compose/foundation-cli
|
|
23
|
+
npm install
|
|
24
|
+
npm link # optional: use `foundation` from anywhere
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
**Shell installer (optional)**
|
|
28
|
+
|
|
29
|
+
```bash
|
|
30
|
+
curl -fsSL https://raw.githubusercontent.com/meshxdata/foundation-compose/main/foundation-cli/install.sh | bash
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
## Commands
|
|
34
|
+
|
|
35
|
+
| Command | Description |
|
|
36
|
+
|---------|-------------|
|
|
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). |
|
|
49
|
+
|
|
50
|
+
### AI Assistant (agent / chat)
|
|
51
|
+
|
|
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.
|
|
54
|
+
|
|
55
|
+
**Auth** (checked in order):
|
|
56
|
+
|
|
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`
|
|
61
|
+
|
|
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`.
|
|
63
|
+
|
|
64
|
+
Install optional deps: `npm install @anthropic-ai/sdk openai`.
|
|
65
|
+
|
|
66
|
+
## Usage
|
|
67
|
+
|
|
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.
|
|
69
|
+
|
|
70
|
+
```bash
|
|
71
|
+
cd /path/to/foundation-compose
|
|
72
|
+
foundation doctor
|
|
73
|
+
foundation up
|
|
74
|
+
foundation status
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
First-time setup:
|
|
78
|
+
|
|
79
|
+
```bash
|
|
80
|
+
git clone https://github.com/meshxdata/foundation-compose
|
|
81
|
+
cd foundation-compose
|
|
82
|
+
npx foundation init
|
|
83
|
+
npx foundation up
|
|
84
|
+
```
|
|
85
|
+
|
|
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.
|
|
89
|
+
|
|
90
|
+
## Requirements
|
|
91
|
+
|
|
92
|
+
- Node.js >= 18
|
|
93
|
+
- Docker (Docker Compose)
|
|
94
|
+
- Git (for `foundation init` submodules)
|
|
95
|
+
|
|
96
|
+
## License
|
|
97
|
+
|
|
98
|
+
MIT
|
package/STRUCTURE.md
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
# Foundation CLI — Project structure
|
|
2
|
+
|
|
3
|
+
Long-term layout for a single package inside the foundation-compose repo. No git submodules required; the CLI lives in `foundation-cli/` and is versioned with the repo.
|
|
4
|
+
|
|
5
|
+
## Layout
|
|
6
|
+
|
|
7
|
+
```
|
|
8
|
+
foundation-cli/
|
|
9
|
+
├── foundation.mjs # Entry point: parses argv, registers commands, runs
|
|
10
|
+
├── package.json
|
|
11
|
+
├── README.md
|
|
12
|
+
├── STRUCTURE.md # This file
|
|
13
|
+
├── install.sh # Optional curl | bash installer
|
|
14
|
+
└── src/
|
|
15
|
+
├── config.js # PKG, CLI_BRAND, printFoundationBanner
|
|
16
|
+
├── project.js # rootDir, requireRoot, isFoundationRoot, findComposeRootUp
|
|
17
|
+
├── shell.js # make(), dockerCompose()
|
|
18
|
+
├── auth.js # resolveAnthropicApiKey, resolveOpenAiApiKey, authHelp, offerClaudeLogin
|
|
19
|
+
├── setup.js # runSetup, runInitWizard, checkEcrRepos
|
|
20
|
+
├── agent.js # gatherStackContext, runAgentSingleTurn, runAgentInteractive, streaming
|
|
21
|
+
├── doctor.js # runDoctor (checks + optional --fix)
|
|
22
|
+
└── commands/
|
|
23
|
+
└── index.js # registerCommands(program) — wires all CLI commands
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
## Conventions
|
|
27
|
+
|
|
28
|
+
- **Single entry**: `foundation.mjs` is the only file executed; it imports `src/commands/index.js` and calls `registerCommands(program)`.
|
|
29
|
+
- **No circular imports**: Flow is entry → commands → lib (config, project, shell, auth, setup, agent, doctor). Lib modules do not import commands.
|
|
30
|
+
- **ESM only**: `"type": "module"` in package.json; all sources use `import`/`export`.
|
|
31
|
+
- **Shared helpers**: Project root detection and `make`/docker are in `project.js` and `shell.js` so every command reuses the same semantics.
|
|
32
|
+
|
|
33
|
+
## Adding a command
|
|
34
|
+
|
|
35
|
+
1. Implement logic in the appropriate `src/*.js` (or a new one if it’s a new domain).
|
|
36
|
+
2. In `src/commands/index.js`, add a `program.command(...).action(...)` that calls your function and uses `requireRoot(program)` or `rootDir()` as needed.
|
|
37
|
+
|
|
38
|
+
## Optional: separate repo (submodule)
|
|
39
|
+
|
|
40
|
+
If you later move the CLI to its own repo and add it as a submodule under `foundation-compose/foundation-cli`:
|
|
41
|
+
|
|
42
|
+
- Keep this same layout inside the submodule.
|
|
43
|
+
- Root `setup.sh` and docs can still say “run `node foundation-cli/foundation.mjs`” or “`npx foundation`” if the package is published.
|
package/foundation.mjs
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
import { Command } from "commander";
|
|
4
|
+
import { registerCommands } from "./src/commands/index.js";
|
|
5
|
+
import { loadPlugins } from "./src/plugins/index.js";
|
|
6
|
+
|
|
7
|
+
const program = new Command();
|
|
8
|
+
const registry = await loadPlugins();
|
|
9
|
+
|
|
10
|
+
registerCommands(program, registry);
|
|
11
|
+
|
|
12
|
+
for (const cmd of registry.commands) {
|
|
13
|
+
cmd.spec(program);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
program.parse();
|
package/package.json
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@meshxdata/fops",
|
|
3
|
+
"version": "0.0.1",
|
|
4
|
+
"description": "CLI to install and manage Foundation data mesh platforms",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"foundation",
|
|
7
|
+
"data-mesh",
|
|
8
|
+
"cli",
|
|
9
|
+
"installer"
|
|
10
|
+
],
|
|
11
|
+
"license": "MIT",
|
|
12
|
+
"type": "module",
|
|
13
|
+
"bin": {
|
|
14
|
+
"fops": "./foundation.mjs"
|
|
15
|
+
},
|
|
16
|
+
"files": [
|
|
17
|
+
"foundation.mjs",
|
|
18
|
+
"src/",
|
|
19
|
+
"README.md",
|
|
20
|
+
"STRUCTURE.md"
|
|
21
|
+
],
|
|
22
|
+
"scripts": {
|
|
23
|
+
"start": "node foundation.mjs",
|
|
24
|
+
"test": "vitest run",
|
|
25
|
+
"test:watch": "vitest"
|
|
26
|
+
},
|
|
27
|
+
"dependencies": {
|
|
28
|
+
"boxen": "^8.0.1",
|
|
29
|
+
"chalk": "^5.3.0",
|
|
30
|
+
"commander": "^12.0.0",
|
|
31
|
+
"execa": "^9.5.2",
|
|
32
|
+
"ink": "^6.7.0",
|
|
33
|
+
"ink-spinner": "^5.0.0",
|
|
34
|
+
"inquirer": "^9.2.23",
|
|
35
|
+
"ora": "^9.3.0",
|
|
36
|
+
"path-key": "^1.0.0",
|
|
37
|
+
"react": "^19.2.4"
|
|
38
|
+
},
|
|
39
|
+
"optionalDependencies": {
|
|
40
|
+
"@anthropic-ai/sdk": "^0.32.0",
|
|
41
|
+
"openai": "^4.73.0"
|
|
42
|
+
},
|
|
43
|
+
"publishConfig": {
|
|
44
|
+
"access": "public"
|
|
45
|
+
},
|
|
46
|
+
"engines": {
|
|
47
|
+
"node": ">=18"
|
|
48
|
+
},
|
|
49
|
+
"devDependencies": {
|
|
50
|
+
"vitest": "^4.0.18"
|
|
51
|
+
}
|
|
52
|
+
}
|
|
@@ -0,0 +1,367 @@
|
|
|
1
|
+
import chalk from "chalk";
|
|
2
|
+
import { execa } from "execa";
|
|
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";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Render inline markdown: **bold**, *italic*, `code`
|
|
10
|
+
*/
|
|
11
|
+
function renderInline(line) {
|
|
12
|
+
return line
|
|
13
|
+
.replace(/\*\*(.+?)\*\*/g, (_, t) => chalk.bold(t))
|
|
14
|
+
.replace(/\*(.+?)\*/g, (_, t) => chalk.italic(t))
|
|
15
|
+
.replace(/`([^`]+)`/g, (_, t) => chalk.yellowBright(t));
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Format AI response markdown for the terminal with ⏺ bullet prefix.
|
|
20
|
+
* Handles headings, bold, italic, inline code, code blocks, lists, blockquotes.
|
|
21
|
+
*/
|
|
22
|
+
export function formatResponse(text) {
|
|
23
|
+
const lines = text.split("\n");
|
|
24
|
+
const out = [];
|
|
25
|
+
let inCode = false;
|
|
26
|
+
let codeLang = "";
|
|
27
|
+
|
|
28
|
+
let codeBlock = [];
|
|
29
|
+
|
|
30
|
+
for (const line of lines) {
|
|
31
|
+
// Code block fences
|
|
32
|
+
if (/^```/.test(line.trim())) {
|
|
33
|
+
if (!inCode) {
|
|
34
|
+
inCode = true;
|
|
35
|
+
codeLang = line.trim().replace(/^```/, "").trim();
|
|
36
|
+
codeBlock = [];
|
|
37
|
+
} else {
|
|
38
|
+
// Closing fence — render the collected block
|
|
39
|
+
const isShell = /^(bash|sh|shell|zsh)?$/.test(codeLang);
|
|
40
|
+
const maxLen = Math.max(...codeBlock.map((l) => l.length), 0);
|
|
41
|
+
const width = Math.max(maxLen + 4, 20);
|
|
42
|
+
const top = chalk.gray(" ┌" + "─".repeat(width) + "┐");
|
|
43
|
+
const bot = chalk.gray(" └" + "─".repeat(width) + "┘");
|
|
44
|
+
out.push(top);
|
|
45
|
+
for (const cl of codeBlock) {
|
|
46
|
+
const prefix = isShell ? chalk.green("$ ") : " ";
|
|
47
|
+
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
|
+
}
|
|
50
|
+
out.push(bot);
|
|
51
|
+
inCode = false;
|
|
52
|
+
codeLang = "";
|
|
53
|
+
codeBlock = [];
|
|
54
|
+
}
|
|
55
|
+
continue;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// Inside code block — collect lines
|
|
59
|
+
if (inCode) {
|
|
60
|
+
codeBlock.push(line);
|
|
61
|
+
continue;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// Headings
|
|
65
|
+
if (/^#{1,3}\s/.test(line)) {
|
|
66
|
+
const content = line.replace(/^#{1,3}\s+/, "");
|
|
67
|
+
out.push(chalk.bold.cyan(content));
|
|
68
|
+
continue;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// Blockquote
|
|
72
|
+
if (/^>\s?/.test(line)) {
|
|
73
|
+
const content = line.replace(/^>\s?/, "");
|
|
74
|
+
out.push(chalk.gray.italic(" " + renderInline(content)));
|
|
75
|
+
continue;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// Unordered list
|
|
79
|
+
if (/^[\s]*[-*+]\s/.test(line)) {
|
|
80
|
+
const content = line.replace(/^[\s]*[-*+]\s+/, "");
|
|
81
|
+
out.push(" " + chalk.cyan("•") + " " + renderInline(content));
|
|
82
|
+
continue;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// Ordered list
|
|
86
|
+
if (/^[\s]*\d+\.\s/.test(line)) {
|
|
87
|
+
const match = line.match(/^[\s]*(\d+)\.\s+(.*)/);
|
|
88
|
+
if (match) {
|
|
89
|
+
out.push(" " + chalk.cyan(match[1] + ".") + " " + renderInline(match[2]));
|
|
90
|
+
continue;
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// Regular text (or blank line)
|
|
95
|
+
out.push(renderInline(line));
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const rendered = out.join("\n").trimEnd();
|
|
99
|
+
const outputLines = rendered.split("\n");
|
|
100
|
+
const bullet = chalk.cyan("⏺");
|
|
101
|
+
return outputLines.map((l, i) => {
|
|
102
|
+
if (i === 0) return `${bullet} ${l}`;
|
|
103
|
+
return ` ${l}`;
|
|
104
|
+
}).join("\n");
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export function extractSuggestedCommands(text) {
|
|
108
|
+
const commands = [];
|
|
109
|
+
const re = /```(?:bash|sh)\s*\n([\s\S]*?)```/gi;
|
|
110
|
+
let m;
|
|
111
|
+
while ((m = re.exec(text)) !== null) {
|
|
112
|
+
const block = m[1].trim().split("\n").map((l) => l.replace(/^\s*#.*$/, "").trim()).filter(Boolean);
|
|
113
|
+
if (block.length) commands.push(block[0]);
|
|
114
|
+
}
|
|
115
|
+
return commands;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Run a shell command string, capturing output.
|
|
120
|
+
* Returns { ok, output } — never throws.
|
|
121
|
+
*/
|
|
122
|
+
async function runShellCommand(root, command, { isLast = true } = {}) {
|
|
123
|
+
const trimmed = command.replace(/\s+/g, " ").trim();
|
|
124
|
+
const branch = isLast ? "└─" : "├─";
|
|
125
|
+
const cont = isLast ? " " : "│ ";
|
|
126
|
+
const prefix = ` ${chalk.gray(cont)}`;
|
|
127
|
+
|
|
128
|
+
console.log(`\n ${chalk.gray(branch)} ${chalk.white(trimmed)} ${chalk.gray("· running")}`);
|
|
129
|
+
|
|
130
|
+
try {
|
|
131
|
+
// Pipe stdout/stderr so docker/compose use clean non-TTY output
|
|
132
|
+
// (no multi-line progress bars). Inherit stdin for interactive prompts.
|
|
133
|
+
const proc = execa("sh", ["-c", trimmed], {
|
|
134
|
+
cwd: root,
|
|
135
|
+
stdio: ["inherit", "pipe", "pipe"],
|
|
136
|
+
reject: false,
|
|
137
|
+
timeout: 120_000,
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
let output = "";
|
|
141
|
+
let lastWasCR = false;
|
|
142
|
+
|
|
143
|
+
const streamChunk = (chunk) => {
|
|
144
|
+
const text = chunk.toString();
|
|
145
|
+
output += text;
|
|
146
|
+
for (const raw of text.split("\n")) {
|
|
147
|
+
if (!raw) continue;
|
|
148
|
+
// Handle \r progress bars — overwrite previous line
|
|
149
|
+
const line = raw.includes("\r") ? raw.split("\r").filter(Boolean).pop() : raw;
|
|
150
|
+
if (!line?.trim()) continue;
|
|
151
|
+
if (lastWasCR) process.stdout.write("\n");
|
|
152
|
+
process.stdout.write(`${prefix}${chalk.gray(line)}`);
|
|
153
|
+
lastWasCR = raw.includes("\r") && !raw.endsWith("\n");
|
|
154
|
+
if (!lastWasCR) process.stdout.write("\n");
|
|
155
|
+
}
|
|
156
|
+
};
|
|
157
|
+
|
|
158
|
+
proc.stdout.on("data", streamChunk);
|
|
159
|
+
proc.stderr.on("data", streamChunk);
|
|
160
|
+
|
|
161
|
+
const result = await proc;
|
|
162
|
+
if (lastWasCR) process.stdout.write("\n");
|
|
163
|
+
|
|
164
|
+
if (result.exitCode !== 0) {
|
|
165
|
+
console.log(`${prefix}${chalk.yellow("⚠ Exit code " + result.exitCode)}`);
|
|
166
|
+
return { ok: false, output: output || `Exit code ${result.exitCode}` };
|
|
167
|
+
}
|
|
168
|
+
console.log(`${prefix}${chalk.green("✓ Done")}`);
|
|
169
|
+
return { ok: true, output };
|
|
170
|
+
} catch (err) {
|
|
171
|
+
const msg = err.message || String(err);
|
|
172
|
+
console.log(`${prefix}${chalk.red("✗ " + msg)}`);
|
|
173
|
+
return { ok: false, output: msg };
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* Present suggested commands in a single picker.
|
|
179
|
+
* Shows each command as a selectable option plus "Run all" / "Skip".
|
|
180
|
+
* Returns array of { command, ok, output } for commands that were run.
|
|
181
|
+
*/
|
|
182
|
+
export async function askToRunCommand(root, replyText) {
|
|
183
|
+
const suggested = extractSuggestedCommands(replyText);
|
|
184
|
+
if (suggested.length === 0) return [];
|
|
185
|
+
|
|
186
|
+
console.log(chalk.cyan("⏺") + chalk.white(" Suggested commands:"));
|
|
187
|
+
|
|
188
|
+
const options = suggested.map((cmd) => ({ label: cmd, value: cmd }));
|
|
189
|
+
if (suggested.length > 1) {
|
|
190
|
+
options.push({ label: "Run all", value: "__run_all__" });
|
|
191
|
+
}
|
|
192
|
+
options.push({ label: "Skip", value: "__skip__" });
|
|
193
|
+
|
|
194
|
+
const choice = await selectOption("Run command?", options);
|
|
195
|
+
|
|
196
|
+
if (choice === null || choice === "__skip__") return [];
|
|
197
|
+
|
|
198
|
+
const toRun = choice === "__run_all__" ? suggested : [choice];
|
|
199
|
+
const results = [];
|
|
200
|
+
|
|
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 });
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
return results;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
export async function runAgentSingleTurn(root, message, opts = {}) {
|
|
211
|
+
const runSuggestions = opts.runSuggestions !== false;
|
|
212
|
+
const useClaudeCode = hasClaudeCode() && hasClaudeCodeAuth();
|
|
213
|
+
const anthropicKey = resolveAnthropicApiKey();
|
|
214
|
+
const openaiKey = resolveOpenAiApiKey();
|
|
215
|
+
|
|
216
|
+
if (!useClaudeCode && !anthropicKey && !openaiKey) {
|
|
217
|
+
authHelp();
|
|
218
|
+
process.exit(1);
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
const context = await gatherStackContext(root);
|
|
222
|
+
const systemContent = FOUNDATION_SYSTEM_PROMPT + "\n\n" + context;
|
|
223
|
+
let replyText = "";
|
|
224
|
+
|
|
225
|
+
const spinner = renderSpinner();
|
|
226
|
+
|
|
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
|
+
}
|
|
249
|
+
} catch (err) {
|
|
250
|
+
spinner.stop();
|
|
251
|
+
if (err.code === "MODULE_NOT_FOUND" || err.message?.includes("Cannot find module")) {
|
|
252
|
+
console.log(chalk.yellow("Install optional deps for agent: npm install @anthropic-ai/sdk openai"));
|
|
253
|
+
process.exit(1);
|
|
254
|
+
}
|
|
255
|
+
const msg = String(err?.message ?? err?.body ?? "");
|
|
256
|
+
if (err?.status === 401 || msg.includes("401") || msg.includes("authentication_error") || msg.includes("invalid x-api-key")) {
|
|
257
|
+
console.log(chalk.red("Authentication failed: invalid or expired API key."));
|
|
258
|
+
const didLogin = await offerClaudeLogin();
|
|
259
|
+
if (didLogin) {
|
|
260
|
+
console.log(chalk.green("Login complete. Run your command again.\n"));
|
|
261
|
+
process.exit(0);
|
|
262
|
+
}
|
|
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"));
|
|
264
|
+
process.exit(1);
|
|
265
|
+
}
|
|
266
|
+
throw err;
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
spinner.stop();
|
|
270
|
+
console.log("");
|
|
271
|
+
console.log(formatResponse(replyText));
|
|
272
|
+
console.log("");
|
|
273
|
+
|
|
274
|
+
if (runSuggestions) {
|
|
275
|
+
const cmdResults = await askToRunCommand(root, replyText);
|
|
276
|
+
if (cmdResults.some((r) => !r.ok)) {
|
|
277
|
+
console.log(chalk.yellow("\n Some commands failed. Run fops chat for interactive help.\n"));
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
export async function runAgentInteractive(root) {
|
|
283
|
+
const useClaudeCode = hasClaudeCode() && hasClaudeCodeAuth();
|
|
284
|
+
const anthropicKey = resolveAnthropicApiKey();
|
|
285
|
+
const openaiKey = resolveOpenAiApiKey();
|
|
286
|
+
|
|
287
|
+
if (!useClaudeCode && !anthropicKey && !openaiKey) {
|
|
288
|
+
authHelp();
|
|
289
|
+
console.log(chalk.gray(" npm install @anthropic-ai/sdk openai # optional deps\n"));
|
|
290
|
+
process.exit(1);
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
if (!useClaudeCode) {
|
|
294
|
+
try {
|
|
295
|
+
await import("@anthropic-ai/sdk");
|
|
296
|
+
} catch (e) {
|
|
297
|
+
if (e.code === "MODULE_NOT_FOUND") {
|
|
298
|
+
console.log(chalk.yellow("Install optional deps: npm install @anthropic-ai/sdk openai\n"));
|
|
299
|
+
process.exit(1);
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
const context = await gatherStackContext(root);
|
|
305
|
+
const systemContent = FOUNDATION_SYSTEM_PROMPT + "\n\n" + context;
|
|
306
|
+
const messages = [];
|
|
307
|
+
|
|
308
|
+
// Show banner
|
|
309
|
+
renderBanner();
|
|
310
|
+
|
|
311
|
+
// Show quick hints
|
|
312
|
+
console.log(chalk.gray(" Try: \"help me set up\" • \"what's running?\" • \"show logs\" • \"run diagnostics\"\n"));
|
|
313
|
+
|
|
314
|
+
// Main chat loop
|
|
315
|
+
while (true) {
|
|
316
|
+
const input = await promptInput();
|
|
317
|
+
|
|
318
|
+
// Exit on null (esc/ctrl+c) or exit command
|
|
319
|
+
if (input === null || /^\/(exit|quit|q)$/i.test(input)) {
|
|
320
|
+
console.log(chalk.gray("\nGoodbye!\n"));
|
|
321
|
+
break;
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
if (!input.trim()) continue;
|
|
325
|
+
|
|
326
|
+
messages.push({ role: "user", content: input });
|
|
327
|
+
|
|
328
|
+
try {
|
|
329
|
+
const replyText = await streamAssistantReply(root, messages, systemContent, {});
|
|
330
|
+
console.log("");
|
|
331
|
+
console.log(formatResponse(replyText));
|
|
332
|
+
console.log("");
|
|
333
|
+
messages.push({ role: "assistant", content: replyText });
|
|
334
|
+
|
|
335
|
+
const cmdResults = await askToRunCommand(root, replyText);
|
|
336
|
+
// Feed command outputs back into context so the AI knows what happened
|
|
337
|
+
if (cmdResults.length > 0) {
|
|
338
|
+
const summary = cmdResults.map((r) =>
|
|
339
|
+
`[Ran: ${r.command}] ${r.ok ? "Success" : "Failed"}:\n${r.output || "(no output)"}`
|
|
340
|
+
).join("\n\n");
|
|
341
|
+
messages.push({ role: "user", content: `[Command results]\n${summary}` });
|
|
342
|
+
|
|
343
|
+
// Auto-follow-up if any command failed
|
|
344
|
+
if (cmdResults.some((r) => !r.ok)) {
|
|
345
|
+
console.log("");
|
|
346
|
+
const followUp = await streamAssistantReply(root, messages, systemContent, {});
|
|
347
|
+
console.log("");
|
|
348
|
+
console.log(formatResponse(followUp));
|
|
349
|
+
console.log("");
|
|
350
|
+
messages.push({ role: "assistant", content: followUp });
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
} catch (err) {
|
|
354
|
+
const msg = String(err?.message ?? err?.body ?? "");
|
|
355
|
+
const is401 = err?.status === 401 || err?.response?.status === 401 ||
|
|
356
|
+
msg.includes("401") || msg.includes("authentication_error") || msg.includes("invalid x-api-key");
|
|
357
|
+
if (is401) {
|
|
358
|
+
console.log(chalk.red("\nAuthentication failed: invalid or expired API key."));
|
|
359
|
+
const didLogin = await offerClaudeLogin();
|
|
360
|
+
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"));
|
|
362
|
+
} else {
|
|
363
|
+
console.log(chalk.red("\nError:"), err.message);
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
}
|