@meshxdata/fops 0.0.1 → 0.0.3

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/src/ui/spinner.js CHANGED
@@ -89,8 +89,9 @@ export function renderSpinner(message) {
89
89
 
90
90
  /**
91
91
  * Thinking display component - shows what the agent is doing
92
+ * Displays reasoning text (thinking) and response preview separately
92
93
  */
93
- function ThinkingDisplay({ status, detail, content }) {
94
+ function ThinkingDisplay({ status, detail, thinking, content }) {
94
95
  const [verb, setVerb] = useState(getRandomVerb());
95
96
  const [frame, setFrame] = useState(0);
96
97
 
@@ -106,6 +107,20 @@ function ThinkingDisplay({ status, detail, content }) {
106
107
  return () => clearInterval(interval);
107
108
  }, []);
108
109
 
110
+ // Show last 4 non-empty lines of thinking text
111
+ const thinkingPreview = (() => {
112
+ if (!thinking) return null;
113
+ const lines = thinking.split("\n").filter(l => l.trim());
114
+ return lines.slice(-4).join("\n");
115
+ })();
116
+
117
+ // Show last 4 non-empty lines of response content
118
+ const contentPreview = (() => {
119
+ if (!content) return null;
120
+ const lines = content.split("\n").filter(l => l.trim());
121
+ return lines.slice(-4).join("\n");
122
+ })();
123
+
109
124
  return h(Box, { flexDirection: "column" },
110
125
  // Status line with spinner
111
126
  h(Box, null,
@@ -113,37 +128,40 @@ function ThinkingDisplay({ status, detail, content }) {
113
128
  h(Text, { color: "yellow" }, ` ${status || verb}... `),
114
129
  detail && h(Text, { dimColor: true }, detail)
115
130
  ),
116
- // Content preview (truncated)
117
- content && h(Box, { marginTop: 1, marginLeft: 2 },
118
- h(Text, { dimColor: true },
119
- content.length > 100 ? content.slice(0, 100) + "..." : content
120
- )
131
+ // Thinking preview (dimmed, italic)
132
+ thinkingPreview && h(Box, { marginLeft: 2 },
133
+ h(Text, { dimColor: true }, thinkingPreview)
134
+ ),
135
+ // Response content preview
136
+ contentPreview && h(Box, { marginLeft: 2 },
137
+ h(Text, { color: "gray" }, contentPreview)
121
138
  )
122
139
  );
123
140
  }
124
141
 
125
142
  // State for thinking display
126
- let thinkingState = { status: "", detail: "", content: "", rerender: null };
143
+ let thinkingState = { status: "", detail: "", thinking: "", content: "", rerender: null };
127
144
 
128
145
  /**
129
146
  * Render thinking display
130
- * Returns controls to update status and content
147
+ * Returns controls to update status, thinking text, and content
131
148
  */
132
149
  export function renderThinking() {
133
- thinkingState = { status: "", detail: "", content: "" };
150
+ thinkingState = { status: "", detail: "", thinking: "", content: "" };
134
151
 
135
152
  const update = () => {
136
153
  if (thinkingState.rerender) {
137
154
  thinkingState.rerender(h(ThinkingDisplay, {
138
155
  status: thinkingState.status,
139
156
  detail: thinkingState.detail,
157
+ thinking: thinkingState.thinking,
140
158
  content: thinkingState.content,
141
159
  }));
142
160
  }
143
161
  };
144
162
 
145
163
  const { rerender, unmount, clear } = render(
146
- h(ThinkingDisplay, { status: "", detail: "", content: "" })
164
+ h(ThinkingDisplay, { status: "", detail: "", thinking: "", content: "" })
147
165
  );
148
166
  thinkingState.rerender = rerender;
149
167
 
@@ -153,6 +171,14 @@ export function renderThinking() {
153
171
  thinkingState.detail = detail;
154
172
  update();
155
173
  },
174
+ setThinking: (text) => {
175
+ thinkingState.thinking = text;
176
+ update();
177
+ },
178
+ appendThinking: (text) => {
179
+ thinkingState.thinking += text;
180
+ update();
181
+ },
156
182
  setContent: (content) => {
157
183
  thinkingState.content = content;
158
184
  update();
package/STRUCTURE.md DELETED
@@ -1,43 +0,0 @@
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.
@@ -1,233 +0,0 @@
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
- });
@@ -1,81 +0,0 @@
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
- });
@@ -1,139 +0,0 @@
1
- import { describe, it, expect, vi, beforeEach } from "vitest";
2
-
3
- vi.mock("execa", () => ({
4
- execa: vi.fn(),
5
- execaSync: vi.fn(),
6
- }));
7
-
8
- vi.mock("../auth/index.js", () => ({
9
- resolveAnthropicApiKey: vi.fn(() => null),
10
- resolveOpenAiApiKey: vi.fn(() => null),
11
- readClaudeCodeKeychain: vi.fn(() => null),
12
- }));
13
-
14
- vi.mock("../ui/index.js", () => ({
15
- renderSpinner: vi.fn(() => ({ stop: vi.fn() })),
16
- }));
17
-
18
- const { execa, execaSync } = await import("execa");
19
- const { readClaudeCodeKeychain } = await import("../auth/index.js");
20
- const { hasClaudeCode, hasClaudeCodeAuth, runViaClaudeCode, streamViaClaudeCode } = await import("./llm.js");
21
-
22
- describe("llm", () => {
23
- beforeEach(() => {
24
- vi.clearAllMocks();
25
- });
26
-
27
- describe("hasClaudeCode", () => {
28
- it("returns true when claude is on PATH", () => {
29
- execaSync.mockReturnValue({ stdout: "/usr/local/bin/claude" });
30
- expect(hasClaudeCode()).toBe(true);
31
- });
32
-
33
- it("returns false when claude is not found", () => {
34
- execaSync.mockImplementation(() => { throw new Error("not found"); });
35
- expect(hasClaudeCode()).toBe(false);
36
- });
37
-
38
- it("calls which claude", () => {
39
- execaSync.mockReturnValue({ stdout: "" });
40
- hasClaudeCode();
41
- expect(execaSync).toHaveBeenCalledWith("which", ["claude"]);
42
- });
43
- });
44
-
45
- describe("hasClaudeCodeAuth", () => {
46
- it("returns true when keychain has accessToken", () => {
47
- readClaudeCodeKeychain.mockReturnValue({ accessToken: "sk-ant-oat01-xxx" });
48
- expect(hasClaudeCodeAuth()).toBe(true);
49
- });
50
-
51
- it("returns false when keychain returns null", () => {
52
- readClaudeCodeKeychain.mockReturnValue(null);
53
- expect(hasClaudeCodeAuth()).toBe(false);
54
- });
55
-
56
- it("returns false when accessToken is missing", () => {
57
- readClaudeCodeKeychain.mockReturnValue({ refreshToken: "rt-xxx" });
58
- expect(hasClaudeCodeAuth()).toBe(false);
59
- });
60
-
61
- it("returns false when accessToken is empty", () => {
62
- readClaudeCodeKeychain.mockReturnValue({ accessToken: "" });
63
- expect(hasClaudeCodeAuth()).toBe(false);
64
- });
65
-
66
- it("returns false when keychain returns empty object", () => {
67
- readClaudeCodeKeychain.mockReturnValue({});
68
- expect(hasClaudeCodeAuth()).toBe(false);
69
- });
70
- });
71
-
72
- describe("runViaClaudeCode", () => {
73
- it("calls claude CLI with prompt and system prompt", async () => {
74
- execa.mockResolvedValue({ stdout: "response text" });
75
- const result = await runViaClaudeCode("my prompt", "system prompt");
76
- expect(result).toBe("response text");
77
- expect(execa).toHaveBeenCalledWith(
78
- "claude",
79
- ["-p", "--no-session-persistence", "--append-system-prompt", "system prompt"],
80
- expect.objectContaining({ input: "my prompt", encoding: "utf8" })
81
- );
82
- });
83
-
84
- it("omits system prompt flag when not provided", async () => {
85
- execa.mockResolvedValue({ stdout: "response" });
86
- await runViaClaudeCode("prompt", null);
87
- const args = execa.mock.calls[0][1];
88
- expect(args).not.toContain("--append-system-prompt");
89
- });
90
-
91
- it("returns empty string when stdout is empty", async () => {
92
- execa.mockResolvedValue({ stdout: "" });
93
- const result = await runViaClaudeCode("prompt", "sys");
94
- expect(result).toBe("");
95
- });
96
-
97
- it("returns empty string when stdout is null", async () => {
98
- execa.mockResolvedValue({ stdout: null });
99
- const result = await runViaClaudeCode("prompt", "sys");
100
- expect(result).toBe("");
101
- });
102
- });
103
-
104
- describe("streamViaClaudeCode", () => {
105
- it("accumulates chunks and returns full text", async () => {
106
- const { EventEmitter } = await import("node:events");
107
- const mockStdout = new EventEmitter();
108
- const mockProc = Promise.resolve();
109
- mockProc.stdout = mockStdout;
110
-
111
- execa.mockReturnValue(mockProc);
112
-
113
- const chunks = [];
114
- const resultPromise = streamViaClaudeCode("prompt", "sys", (chunk) => chunks.push(chunk));
115
-
116
- mockStdout.emit("data", Buffer.from("hello "));
117
- mockStdout.emit("data", Buffer.from("world"));
118
-
119
- const result = await resultPromise;
120
- expect(result).toBe("hello world");
121
- expect(chunks).toEqual(["hello ", "world"]);
122
- });
123
-
124
- it("works without onChunk callback", async () => {
125
- const { EventEmitter } = await import("node:events");
126
- const mockStdout = new EventEmitter();
127
- const mockProc = Promise.resolve();
128
- mockProc.stdout = mockStdout;
129
-
130
- execa.mockReturnValue(mockProc);
131
-
132
- const resultPromise = streamViaClaudeCode("prompt", "sys");
133
- mockStdout.emit("data", Buffer.from("data"));
134
-
135
- const result = await resultPromise;
136
- expect(result).toBe("data");
137
- });
138
- });
139
- });