@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
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import { formatResponse, extractSuggestedCommands } from "./agent.js";
|
|
3
|
+
|
|
4
|
+
describe("agent", () => {
|
|
5
|
+
describe("formatResponse", () => {
|
|
6
|
+
it("prefixes first line with bullet", () => {
|
|
7
|
+
const result = formatResponse("hello");
|
|
8
|
+
expect(result).toContain("hello");
|
|
9
|
+
expect(result).toMatch(/⏺/);
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
it("renders headings", () => {
|
|
13
|
+
const result = formatResponse("# My Heading\nsome text");
|
|
14
|
+
expect(result).toContain("My Heading");
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
it("renders h2 headings", () => {
|
|
18
|
+
const result = formatResponse("## Sub Heading");
|
|
19
|
+
expect(result).toContain("Sub Heading");
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it("renders h3 headings", () => {
|
|
23
|
+
const result = formatResponse("### Third Level");
|
|
24
|
+
expect(result).toContain("Third Level");
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it("renders unordered list items with bullet", () => {
|
|
28
|
+
const result = formatResponse("- item one\n- item two");
|
|
29
|
+
expect(result).toContain("item one");
|
|
30
|
+
expect(result).toContain("item two");
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it("renders * list items", () => {
|
|
34
|
+
const result = formatResponse("* star item");
|
|
35
|
+
expect(result).toContain("star item");
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it("renders + list items", () => {
|
|
39
|
+
const result = formatResponse("+ plus item");
|
|
40
|
+
expect(result).toContain("plus item");
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it("renders ordered list items", () => {
|
|
44
|
+
const result = formatResponse("1. first\n2. second");
|
|
45
|
+
expect(result).toContain("first");
|
|
46
|
+
expect(result).toContain("second");
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it("renders code blocks", () => {
|
|
50
|
+
const result = formatResponse("```bash\nfops doctor\n```");
|
|
51
|
+
expect(result).toContain("fops doctor");
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it("renders code blocks with language tag", () => {
|
|
55
|
+
const result = formatResponse("```python\nprint('hi')\n```");
|
|
56
|
+
expect(result).toContain("print('hi')");
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it("renders code blocks without language", () => {
|
|
60
|
+
const result = formatResponse("```\nplain code\n```");
|
|
61
|
+
expect(result).toContain("plain code");
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it("renders blockquotes", () => {
|
|
65
|
+
const result = formatResponse("> some quote");
|
|
66
|
+
expect(result).toContain("some quote");
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it("renders blockquote without space after >", () => {
|
|
70
|
+
const result = formatResponse(">tight quote");
|
|
71
|
+
expect(result).toContain("tight quote");
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it("handles empty text", () => {
|
|
75
|
+
const result = formatResponse("");
|
|
76
|
+
expect(result).toContain("⏺");
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it("handles multiple blank lines", () => {
|
|
80
|
+
const result = formatResponse("line1\n\n\nline2");
|
|
81
|
+
expect(result).toContain("line1");
|
|
82
|
+
expect(result).toContain("line2");
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it("renders inline bold", () => {
|
|
86
|
+
const result = formatResponse("this is **bold** text");
|
|
87
|
+
expect(result).toContain("bold");
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it("renders inline italic", () => {
|
|
91
|
+
const result = formatResponse("this is *italic* text");
|
|
92
|
+
expect(result).toContain("italic");
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it("renders inline code", () => {
|
|
96
|
+
const result = formatResponse("run `fops up` now");
|
|
97
|
+
expect(result).toContain("fops up");
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
it("indents subsequent lines with spaces", () => {
|
|
101
|
+
const result = formatResponse("first\nsecond\nthird");
|
|
102
|
+
const lines = result.split("\n");
|
|
103
|
+
expect(lines[0]).toMatch(/⏺/);
|
|
104
|
+
expect(lines[1]).toMatch(/^\s\s/); // indented
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
it("handles multi-line code block correctly", () => {
|
|
108
|
+
const input = "Before:\n```bash\nline1\nline2\nline3\n```\nAfter.";
|
|
109
|
+
const result = formatResponse(input);
|
|
110
|
+
expect(result).toContain("line1");
|
|
111
|
+
expect(result).toContain("line2");
|
|
112
|
+
expect(result).toContain("line3");
|
|
113
|
+
expect(result).toContain("After.");
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
it("handles indented list items", () => {
|
|
117
|
+
const result = formatResponse(" - indented item");
|
|
118
|
+
expect(result).toContain("indented item");
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
it("handles mixed content", () => {
|
|
122
|
+
const input = [
|
|
123
|
+
"# Title",
|
|
124
|
+
"Some text with **bold** and `code`.",
|
|
125
|
+
"",
|
|
126
|
+
"- item 1",
|
|
127
|
+
"- item 2",
|
|
128
|
+
"",
|
|
129
|
+
"```bash",
|
|
130
|
+
"fops doctor",
|
|
131
|
+
"```",
|
|
132
|
+
"",
|
|
133
|
+
"> A quote",
|
|
134
|
+
].join("\n");
|
|
135
|
+
const result = formatResponse(input);
|
|
136
|
+
expect(result).toContain("Title");
|
|
137
|
+
expect(result).toContain("bold");
|
|
138
|
+
expect(result).toContain("fops doctor");
|
|
139
|
+
expect(result).toContain("A quote");
|
|
140
|
+
});
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
describe("extractSuggestedCommands", () => {
|
|
144
|
+
it("extracts commands from bash code blocks", () => {
|
|
145
|
+
const text = "Try running:\n```bash\nfops doctor\n```\nThen:\n```bash\nfops up\n```";
|
|
146
|
+
const cmds = extractSuggestedCommands(text);
|
|
147
|
+
expect(cmds).toEqual(["fops doctor", "fops up"]);
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
it("extracts from sh blocks", () => {
|
|
151
|
+
const text = "```sh\necho hello\n```";
|
|
152
|
+
const cmds = extractSuggestedCommands(text);
|
|
153
|
+
expect(cmds).toEqual(["echo hello"]);
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
it("returns empty for non-bash blocks", () => {
|
|
157
|
+
const text = "```json\n{}\n```";
|
|
158
|
+
const cmds = extractSuggestedCommands(text);
|
|
159
|
+
expect(cmds).toEqual([]);
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
it("returns empty for unlabeled code blocks", () => {
|
|
163
|
+
const text = "```\nsome code\n```";
|
|
164
|
+
const cmds = extractSuggestedCommands(text);
|
|
165
|
+
expect(cmds).toEqual([]);
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
it("returns empty when no code blocks", () => {
|
|
169
|
+
const cmds = extractSuggestedCommands("just some text");
|
|
170
|
+
expect(cmds).toEqual([]);
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
it("returns empty for empty string", () => {
|
|
174
|
+
const cmds = extractSuggestedCommands("");
|
|
175
|
+
expect(cmds).toEqual([]);
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
it("skips comment-only lines", () => {
|
|
179
|
+
const text = "```bash\n# this is a comment\nfops status\n```";
|
|
180
|
+
const cmds = extractSuggestedCommands(text);
|
|
181
|
+
expect(cmds).toEqual(["fops status"]);
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
it("takes only first command from multi-line blocks", () => {
|
|
185
|
+
const text = "```bash\nfops doctor\nfops up\n```";
|
|
186
|
+
const cmds = extractSuggestedCommands(text);
|
|
187
|
+
expect(cmds).toEqual(["fops doctor"]);
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
it("skips blocks where all lines are comments", () => {
|
|
191
|
+
const text = "```bash\n# just a comment\n# another comment\n```";
|
|
192
|
+
const cmds = extractSuggestedCommands(text);
|
|
193
|
+
expect(cmds).toEqual([]);
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
it("handles BASH uppercase language tag", () => {
|
|
197
|
+
const text = "```BASH\nfops status\n```";
|
|
198
|
+
const cmds = extractSuggestedCommands(text);
|
|
199
|
+
expect(cmds).toEqual(["fops status"]);
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
it("handles Bash mixed case", () => {
|
|
203
|
+
const text = "```Bash\nfops logs\n```";
|
|
204
|
+
const cmds = extractSuggestedCommands(text);
|
|
205
|
+
expect(cmds).toEqual(["fops logs"]);
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
it("trims whitespace from commands", () => {
|
|
209
|
+
const text = "```bash\n fops doctor \n```";
|
|
210
|
+
const cmds = extractSuggestedCommands(text);
|
|
211
|
+
expect(cmds).toEqual(["fops doctor"]);
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
it("handles multiple blocks interleaved with text", () => {
|
|
215
|
+
const text = [
|
|
216
|
+
"First do this:",
|
|
217
|
+
"```bash",
|
|
218
|
+
"fops init",
|
|
219
|
+
"```",
|
|
220
|
+
"Then check:",
|
|
221
|
+
"```bash",
|
|
222
|
+
"fops doctor",
|
|
223
|
+
"```",
|
|
224
|
+
"Finally:",
|
|
225
|
+
"```bash",
|
|
226
|
+
"fops up",
|
|
227
|
+
"```",
|
|
228
|
+
].join("\n");
|
|
229
|
+
const cmds = extractSuggestedCommands(text);
|
|
230
|
+
expect(cmds).toEqual(["fops init", "fops doctor", "fops up"]);
|
|
231
|
+
});
|
|
232
|
+
});
|
|
233
|
+
});
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { execa } from "execa";
|
|
4
|
+
import { loadSkills } from "../plugins/index.js";
|
|
5
|
+
|
|
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
|
+
|
|
8
|
+
## Personality
|
|
9
|
+
- Terse. Precise. Slightly amused by broken things.
|
|
10
|
+
- Use short sentences. No corporate fluff.
|
|
11
|
+
- When something is broken, say what's broken and how to fix it. No preambles.
|
|
12
|
+
- Treat the user like a peer, not a customer.
|
|
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
|
+
## Commands
|
|
21
|
+
When suggesting commands, ALWAYS use \`fops\` commands, not raw \`make\` or \`git clone\`. Output each in its own fenced block.
|
|
22
|
+
|
|
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
|
|
52
|
+
|
|
53
|
+
## Security Rules
|
|
54
|
+
- Never output API keys, passwords, or tokens in responses
|
|
55
|
+
- Suggest secure alternatives when users try unsafe operations
|
|
56
|
+
- Validate file paths before suggesting file operations`;
|
|
57
|
+
|
|
58
|
+
export function getFoundationContextBlock(root) {
|
|
59
|
+
if (!root) return "No Foundation project root found. User may need to run fops init.";
|
|
60
|
+
return `Project root: ${root}. Commands run in this directory.`;
|
|
61
|
+
}
|
|
62
|
+
|
|
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.");
|
|
82
|
+
}
|
|
83
|
+
} catch {
|
|
84
|
+
parts.push("Docker: not available or not running.");
|
|
85
|
+
}
|
|
86
|
+
}
|
|
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
|
+
}
|
|
113
|
+
|
|
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(", "));
|
|
120
|
+
|
|
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')");
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// Inject plugin skills into context
|
|
133
|
+
try {
|
|
134
|
+
const skills = await loadSkills();
|
|
135
|
+
if (skills.length) {
|
|
136
|
+
parts.push("## Additional Skills\n" + skills.map((s) => s.content).join("\n\n"));
|
|
137
|
+
}
|
|
138
|
+
} catch {
|
|
139
|
+
// skip if skill loading fails
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
return parts.join("\n\n");
|
|
143
|
+
}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import { describe, it, expect, vi } from "vitest";
|
|
2
|
+
import { FOUNDATION_SYSTEM_PROMPT, getFoundationContextBlock } from "./context.js";
|
|
3
|
+
|
|
4
|
+
describe("context", () => {
|
|
5
|
+
describe("FOUNDATION_SYSTEM_PROMPT", () => {
|
|
6
|
+
it("defines FOPS personality", () => {
|
|
7
|
+
expect(FOUNDATION_SYSTEM_PROMPT).toContain("FOPS");
|
|
8
|
+
expect(FOUNDATION_SYSTEM_PROMPT).toContain("Foundation Operator");
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
it("includes capabilities", () => {
|
|
12
|
+
expect(FOUNDATION_SYSTEM_PROMPT).toContain("Setup & Init");
|
|
13
|
+
expect(FOUNDATION_SYSTEM_PROMPT).toContain("Operations");
|
|
14
|
+
expect(FOUNDATION_SYSTEM_PROMPT).toContain("Debugging");
|
|
15
|
+
expect(FOUNDATION_SYSTEM_PROMPT).toContain("Security");
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
it("lists available commands", () => {
|
|
19
|
+
expect(FOUNDATION_SYSTEM_PROMPT).toContain("fops init");
|
|
20
|
+
expect(FOUNDATION_SYSTEM_PROMPT).toContain("fops up");
|
|
21
|
+
expect(FOUNDATION_SYSTEM_PROMPT).toContain("fops down");
|
|
22
|
+
expect(FOUNDATION_SYSTEM_PROMPT).toContain("fops doctor");
|
|
23
|
+
expect(FOUNDATION_SYSTEM_PROMPT).toContain("fops status");
|
|
24
|
+
expect(FOUNDATION_SYSTEM_PROMPT).toContain("fops logs");
|
|
25
|
+
expect(FOUNDATION_SYSTEM_PROMPT).toContain("fops restart");
|
|
26
|
+
expect(FOUNDATION_SYSTEM_PROMPT).toContain("fops login");
|
|
27
|
+
expect(FOUNDATION_SYSTEM_PROMPT).toContain("fops chat");
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it("includes security rules", () => {
|
|
31
|
+
expect(FOUNDATION_SYSTEM_PROMPT).toContain("Never output API keys");
|
|
32
|
+
expect(FOUNDATION_SYSTEM_PROMPT).toContain("Validate file paths");
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it("includes service ports", () => {
|
|
36
|
+
expect(FOUNDATION_SYSTEM_PROMPT).toContain("9001");
|
|
37
|
+
expect(FOUNDATION_SYSTEM_PROMPT).toContain("3002");
|
|
38
|
+
expect(FOUNDATION_SYSTEM_PROMPT).toContain("5432");
|
|
39
|
+
expect(FOUNDATION_SYSTEM_PROMPT).toContain("9092");
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it("includes personality traits", () => {
|
|
43
|
+
expect(FOUNDATION_SYSTEM_PROMPT).toContain("Terse");
|
|
44
|
+
expect(FOUNDATION_SYSTEM_PROMPT).toContain("Precise");
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it("includes setup checklist", () => {
|
|
48
|
+
expect(FOUNDATION_SYSTEM_PROMPT).toContain("Setup Checklist");
|
|
49
|
+
expect(FOUNDATION_SYSTEM_PROMPT).toContain("npm install -g");
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it("instructs to use fops commands not raw make", () => {
|
|
53
|
+
expect(FOUNDATION_SYSTEM_PROMPT).toContain("ALWAYS use `fops` commands");
|
|
54
|
+
});
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
describe("getFoundationContextBlock", () => {
|
|
58
|
+
it("returns project root message when root is provided", () => {
|
|
59
|
+
const result = getFoundationContextBlock("/path/to/project");
|
|
60
|
+
expect(result).toContain("/path/to/project");
|
|
61
|
+
expect(result).toContain("Project root");
|
|
62
|
+
expect(result).toContain("Commands run in this directory");
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it("returns init message when root is null", () => {
|
|
66
|
+
const result = getFoundationContextBlock(null);
|
|
67
|
+
expect(result).toContain("fops init");
|
|
68
|
+
expect(result).toContain("No Foundation project root found");
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it("returns init message when root is undefined", () => {
|
|
72
|
+
const result = getFoundationContextBlock(undefined);
|
|
73
|
+
expect(result).toContain("fops init");
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it("returns init message when root is empty string", () => {
|
|
77
|
+
const result = getFoundationContextBlock("");
|
|
78
|
+
expect(result).toContain("fops init");
|
|
79
|
+
});
|
|
80
|
+
});
|
|
81
|
+
});
|
package/src/agent/llm.js
ADDED
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
import chalk from "chalk";
|
|
2
|
+
import { execa, execaSync } from "execa";
|
|
3
|
+
import { resolveAnthropicApiKey, resolveOpenAiApiKey, readClaudeCodeKeychain } from "../auth/index.js";
|
|
4
|
+
import { renderSpinner } from "../ui/index.js";
|
|
5
|
+
|
|
6
|
+
// Check if Claude Code CLI is available
|
|
7
|
+
export function hasClaudeCode() {
|
|
8
|
+
try {
|
|
9
|
+
execaSync("which", ["claude"]);
|
|
10
|
+
return true;
|
|
11
|
+
} catch {
|
|
12
|
+
return false;
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
// Check if user has Claude Code OAuth credentials
|
|
17
|
+
export function hasClaudeCodeAuth() {
|
|
18
|
+
const creds = readClaudeCodeKeychain();
|
|
19
|
+
return !!creds?.accessToken;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Build a single prompt string from the full conversation history
|
|
24
|
+
* so Claude Code preserves context between turns.
|
|
25
|
+
*/
|
|
26
|
+
function buildConversationPrompt(messages) {
|
|
27
|
+
if (messages.length <= 1) {
|
|
28
|
+
return messages[0]?.content || "";
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
return messages.map((m) => {
|
|
32
|
+
const role = m.role === "user" ? "User" : "Assistant";
|
|
33
|
+
return `${role}: ${m.content}`;
|
|
34
|
+
}).join("\n\n");
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Run a prompt through Claude Code CLI (uses OAuth auth)
|
|
39
|
+
*/
|
|
40
|
+
export async function runViaClaudeCode(prompt, systemPrompt) {
|
|
41
|
+
const args = ["-p", "--no-session-persistence"];
|
|
42
|
+
if (systemPrompt) {
|
|
43
|
+
args.push("--append-system-prompt", systemPrompt);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const { stdout } = await execa("claude", args, {
|
|
47
|
+
input: prompt,
|
|
48
|
+
encoding: "utf8",
|
|
49
|
+
reject: false,
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
return stdout || "";
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Stream response via Claude Code CLI with thinking display
|
|
57
|
+
*/
|
|
58
|
+
export async function streamViaClaudeCode(prompt, systemPrompt, onChunk, onThinking) {
|
|
59
|
+
const args = ["-p", "--no-session-persistence"];
|
|
60
|
+
if (systemPrompt) {
|
|
61
|
+
args.push("--append-system-prompt", systemPrompt);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const proc = execa("claude", args, {
|
|
65
|
+
input: prompt,
|
|
66
|
+
encoding: "utf8",
|
|
67
|
+
reject: false,
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
let fullText = "";
|
|
71
|
+
|
|
72
|
+
proc.stdout.on("data", (chunk) => {
|
|
73
|
+
const text = chunk.toString();
|
|
74
|
+
fullText += text;
|
|
75
|
+
if (onChunk) onChunk(text);
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
await proc;
|
|
79
|
+
return fullText;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export async function streamAssistantReply(root, messages, systemContent, opts) {
|
|
83
|
+
const useClaudeCode = hasClaudeCode() && hasClaudeCodeAuth();
|
|
84
|
+
const anthropicKey = resolveAnthropicApiKey();
|
|
85
|
+
const openaiKey = resolveOpenAiApiKey();
|
|
86
|
+
const model = opts.model || (anthropicKey ? "claude-sonnet-4-20250514" : "gpt-4o-mini");
|
|
87
|
+
|
|
88
|
+
let fullText = "";
|
|
89
|
+
const spinner = renderSpinner();
|
|
90
|
+
|
|
91
|
+
try {
|
|
92
|
+
if (useClaudeCode) {
|
|
93
|
+
// Build full conversation prompt so context is preserved between turns
|
|
94
|
+
const prompt = buildConversationPrompt(messages);
|
|
95
|
+
fullText = await streamViaClaudeCode(prompt, systemContent);
|
|
96
|
+
} else if (anthropicKey) {
|
|
97
|
+
const { default: Anthropic } = await import("@anthropic-ai/sdk");
|
|
98
|
+
const client = new Anthropic({ apiKey: anthropicKey });
|
|
99
|
+
const stream = await client.messages.create({
|
|
100
|
+
model, max_tokens: 2048, system: systemContent, messages, stream: true,
|
|
101
|
+
});
|
|
102
|
+
for await (const event of stream) {
|
|
103
|
+
if (event.type === "content_block_delta" && event.delta?.type === "text_delta" && event.delta.text) {
|
|
104
|
+
fullText += event.delta.text;
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
} else if (openaiKey) {
|
|
108
|
+
const OpenAI = (await import("openai")).default;
|
|
109
|
+
const client = new OpenAI({ apiKey: openaiKey });
|
|
110
|
+
const stream = await client.chat.completions.create({
|
|
111
|
+
model, max_tokens: 2048,
|
|
112
|
+
messages: [{ role: "system", content: systemContent }, ...messages],
|
|
113
|
+
stream: true,
|
|
114
|
+
});
|
|
115
|
+
for await (const chunk of stream) {
|
|
116
|
+
const delta = chunk.choices?.[0]?.delta?.content;
|
|
117
|
+
if (delta) fullText += delta;
|
|
118
|
+
}
|
|
119
|
+
} else {
|
|
120
|
+
throw new Error("No API key (use ANTHROPIC_API_KEY, OPENAI_API_KEY, or ~/.claude auth)");
|
|
121
|
+
}
|
|
122
|
+
} finally {
|
|
123
|
+
spinner.stop();
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
return fullText;
|
|
127
|
+
}
|