@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.
Files changed (57) hide show
  1. package/README.md +98 -0
  2. package/STRUCTURE.md +43 -0
  3. package/foundation.mjs +16 -0
  4. package/package.json +52 -0
  5. package/src/agent/agent.js +367 -0
  6. package/src/agent/agent.test.js +233 -0
  7. package/src/agent/context.js +143 -0
  8. package/src/agent/context.test.js +81 -0
  9. package/src/agent/index.js +2 -0
  10. package/src/agent/llm.js +127 -0
  11. package/src/agent/llm.test.js +139 -0
  12. package/src/auth/index.js +4 -0
  13. package/src/auth/keychain.js +58 -0
  14. package/src/auth/keychain.test.js +185 -0
  15. package/src/auth/login.js +421 -0
  16. package/src/auth/login.test.js +192 -0
  17. package/src/auth/oauth.js +203 -0
  18. package/src/auth/oauth.test.js +118 -0
  19. package/src/auth/resolve.js +78 -0
  20. package/src/auth/resolve.test.js +153 -0
  21. package/src/commands/index.js +268 -0
  22. package/src/config.js +24 -0
  23. package/src/config.test.js +70 -0
  24. package/src/doctor.js +487 -0
  25. package/src/doctor.test.js +134 -0
  26. package/src/plugins/api.js +37 -0
  27. package/src/plugins/api.test.js +95 -0
  28. package/src/plugins/discovery.js +78 -0
  29. package/src/plugins/discovery.test.js +92 -0
  30. package/src/plugins/hooks.js +13 -0
  31. package/src/plugins/hooks.test.js +118 -0
  32. package/src/plugins/index.js +3 -0
  33. package/src/plugins/loader.js +110 -0
  34. package/src/plugins/manifest.js +26 -0
  35. package/src/plugins/manifest.test.js +106 -0
  36. package/src/plugins/registry.js +14 -0
  37. package/src/plugins/registry.test.js +43 -0
  38. package/src/plugins/skills.js +126 -0
  39. package/src/plugins/skills.test.js +173 -0
  40. package/src/project.js +61 -0
  41. package/src/project.test.js +196 -0
  42. package/src/setup/aws.js +369 -0
  43. package/src/setup/aws.test.js +280 -0
  44. package/src/setup/index.js +3 -0
  45. package/src/setup/setup.js +161 -0
  46. package/src/setup/wizard.js +119 -0
  47. package/src/shell.js +9 -0
  48. package/src/shell.test.js +72 -0
  49. package/src/skills/foundation/SKILL.md +107 -0
  50. package/src/ui/banner.js +56 -0
  51. package/src/ui/banner.test.js +97 -0
  52. package/src/ui/confirm.js +97 -0
  53. package/src/ui/index.js +5 -0
  54. package/src/ui/input.js +199 -0
  55. package/src/ui/spinner.js +170 -0
  56. package/src/ui/spinner.test.js +29 -0
  57. 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
+ });
@@ -0,0 +1,2 @@
1
+ export { runAgentSingleTurn, runAgentInteractive } from "./agent.js";
2
+ export { gatherStackContext } from "./context.js";
@@ -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
+ }