@meshxdata/fops 0.0.1 → 0.0.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (52) hide show
  1. package/README.md +62 -40
  2. package/package.json +4 -3
  3. package/src/agent/agent.js +161 -68
  4. package/src/agent/agents.js +224 -0
  5. package/src/agent/context.js +287 -96
  6. package/src/agent/index.js +1 -0
  7. package/src/agent/llm.js +134 -20
  8. package/src/auth/coda.js +128 -0
  9. package/src/auth/index.js +1 -0
  10. package/src/auth/login.js +13 -13
  11. package/src/auth/oauth.js +4 -4
  12. package/src/commands/index.js +94 -21
  13. package/src/config.js +2 -2
  14. package/src/doctor.js +208 -22
  15. package/src/feature-flags.js +197 -0
  16. package/src/plugins/api.js +23 -0
  17. package/src/plugins/builtins/stack-api.js +36 -0
  18. package/src/plugins/index.js +1 -0
  19. package/src/plugins/knowledge.js +124 -0
  20. package/src/plugins/loader.js +67 -0
  21. package/src/plugins/registry.js +3 -0
  22. package/src/project.js +20 -1
  23. package/src/setup/aws.js +7 -7
  24. package/src/setup/setup.js +18 -12
  25. package/src/setup/wizard.js +86 -15
  26. package/src/shell.js +2 -2
  27. package/src/skills/foundation/SKILL.md +200 -66
  28. package/src/ui/confirm.js +3 -2
  29. package/src/ui/input.js +31 -34
  30. package/src/ui/spinner.js +39 -13
  31. package/src/ui/streaming.js +2 -2
  32. package/STRUCTURE.md +0 -43
  33. package/src/agent/agent.test.js +0 -233
  34. package/src/agent/context.test.js +0 -81
  35. package/src/agent/llm.test.js +0 -139
  36. package/src/auth/keychain.test.js +0 -185
  37. package/src/auth/login.test.js +0 -192
  38. package/src/auth/oauth.test.js +0 -118
  39. package/src/auth/resolve.test.js +0 -153
  40. package/src/config.test.js +0 -70
  41. package/src/doctor.test.js +0 -134
  42. package/src/plugins/api.test.js +0 -95
  43. package/src/plugins/discovery.test.js +0 -92
  44. package/src/plugins/hooks.test.js +0 -118
  45. package/src/plugins/manifest.test.js +0 -106
  46. package/src/plugins/registry.test.js +0 -43
  47. package/src/plugins/skills.test.js +0 -173
  48. package/src/project.test.js +0 -196
  49. package/src/setup/aws.test.js +0 -280
  50. package/src/shell.test.js +0 -72
  51. package/src/ui/banner.test.js +0 -97
  52. package/src/ui/spinner.test.js +0 -29
package/src/agent/llm.js CHANGED
@@ -1,7 +1,7 @@
1
1
  import chalk from "chalk";
2
2
  import { execa, execaSync } from "execa";
3
3
  import { resolveAnthropicApiKey, resolveOpenAiApiKey, readClaudeCodeKeychain } from "../auth/index.js";
4
- import { renderSpinner } from "../ui/index.js";
4
+ import { renderThinking } from "../ui/index.js";
5
5
 
6
6
  // Check if Claude Code CLI is available
7
7
  export function hasClaudeCode() {
@@ -37,10 +37,10 @@ function buildConversationPrompt(messages) {
37
37
  /**
38
38
  * Run a prompt through Claude Code CLI (uses OAuth auth)
39
39
  */
40
- export async function runViaClaudeCode(prompt, systemPrompt) {
40
+ export async function runViaClaudeCode(prompt, systemPrompt, { replaceSystemPrompt = false } = {}) {
41
41
  const args = ["-p", "--no-session-persistence"];
42
42
  if (systemPrompt) {
43
- args.push("--append-system-prompt", systemPrompt);
43
+ args.push(replaceSystemPrompt ? "--system-prompt" : "--append-system-prompt", systemPrompt);
44
44
  }
45
45
 
46
46
  const { stdout } = await execa("claude", args, {
@@ -53,12 +53,20 @@ export async function runViaClaudeCode(prompt, systemPrompt) {
53
53
  }
54
54
 
55
55
  /**
56
- * Stream response via Claude Code CLI with thinking display
56
+ * Stream response via Claude Code CLI with thinking display.
57
+ * Uses --output-format stream-json --include-partial-messages to get
58
+ * token-level streaming events (content_block_delta for text and thinking).
57
59
  */
58
- export async function streamViaClaudeCode(prompt, systemPrompt, onChunk, onThinking) {
59
- const args = ["-p", "--no-session-persistence"];
60
+ export async function streamViaClaudeCode(prompt, systemPrompt, onChunk, onThinking, onBlockStart, { replaceSystemPrompt = false } = {}) {
61
+ const args = [
62
+ "-p",
63
+ "--output-format", "stream-json",
64
+ "--verbose",
65
+ "--include-partial-messages",
66
+ "--no-session-persistence",
67
+ ];
60
68
  if (systemPrompt) {
61
- args.push("--append-system-prompt", systemPrompt);
69
+ args.push(replaceSystemPrompt ? "--system-prompt" : "--append-system-prompt", systemPrompt);
62
70
  }
63
71
 
64
72
  const proc = execa("claude", args, {
@@ -68,14 +76,62 @@ export async function streamViaClaudeCode(prompt, systemPrompt, onChunk, onThink
68
76
  });
69
77
 
70
78
  let fullText = "";
79
+ let lineBuf = "";
71
80
 
72
81
  proc.stdout.on("data", (chunk) => {
73
- const text = chunk.toString();
74
- fullText += text;
75
- if (onChunk) onChunk(text);
82
+ lineBuf += chunk.toString();
83
+ const lines = lineBuf.split("\n");
84
+ lineBuf = lines.pop(); // keep incomplete line
85
+
86
+ for (const line of lines) {
87
+ if (!line.trim()) continue;
88
+ let evt;
89
+ try { evt = JSON.parse(line); } catch { continue; }
90
+
91
+ if (evt.type === "stream_event") {
92
+ const inner = evt.event;
93
+ if (inner?.type === "content_block_start") {
94
+ const blockType = inner.content_block?.type;
95
+ if (onBlockStart) onBlockStart(blockType);
96
+ } else if (inner?.type === "content_block_delta") {
97
+ if (inner.delta?.type === "thinking_delta" && inner.delta.thinking) {
98
+ if (onThinking) onThinking(inner.delta.thinking);
99
+ }
100
+ if (inner.delta?.type === "text_delta" && inner.delta.text) {
101
+ fullText += inner.delta.text;
102
+ if (onChunk) onChunk(inner.delta.text);
103
+ }
104
+ }
105
+ } else if (evt.type === "assistant") {
106
+ // Final assistant message — extract any remaining text
107
+ const content = evt.message?.content || [];
108
+ const textParts = content.filter(b => b.type === "text").map(b => b.text).join("");
109
+ // Only use this if streaming didn't capture everything
110
+ if (textParts && !fullText) {
111
+ fullText = textParts;
112
+ if (onChunk) onChunk(textParts);
113
+ }
114
+ }
115
+ }
76
116
  });
77
117
 
78
118
  await proc;
119
+
120
+ // Process any remaining buffer
121
+ if (lineBuf.trim()) {
122
+ try {
123
+ const evt = JSON.parse(lineBuf);
124
+ if (evt.type === "assistant") {
125
+ const content = evt.message?.content || [];
126
+ const textParts = content.filter(b => b.type === "text").map(b => b.text).join("");
127
+ if (textParts && !fullText) {
128
+ fullText = textParts;
129
+ if (onChunk) onChunk(textParts);
130
+ }
131
+ }
132
+ } catch { /* ignore */ }
133
+ }
134
+
79
135
  return fullText;
80
136
  }
81
137
 
@@ -86,22 +142,76 @@ export async function streamAssistantReply(root, messages, systemContent, opts)
86
142
  const model = opts.model || (anthropicKey ? "claude-sonnet-4-20250514" : "gpt-4o-mini");
87
143
 
88
144
  let fullText = "";
89
- const spinner = renderSpinner();
145
+ const display = renderThinking();
90
146
 
91
147
  try {
92
148
  if (useClaudeCode) {
93
- // Build full conversation prompt so context is preserved between turns
94
149
  const prompt = buildConversationPrompt(messages);
95
- fullText = await streamViaClaudeCode(prompt, systemContent);
150
+ fullText = await streamViaClaudeCode(
151
+ prompt,
152
+ systemContent,
153
+ (chunk) => {
154
+ display.appendContent(chunk);
155
+ },
156
+ (thinking) => {
157
+ display.appendThinking(thinking);
158
+ },
159
+ (blockType) => {
160
+ if (blockType === "thinking") {
161
+ display.setStatus("Reasoning");
162
+ } else if (blockType === "text") {
163
+ display.setThinking(""); // clear thinking preview
164
+ display.setStatus("Responding");
165
+ }
166
+ },
167
+ { replaceSystemPrompt: !!opts.replaceSystemPrompt },
168
+ );
96
169
  } else if (anthropicKey) {
97
170
  const { default: Anthropic } = await import("@anthropic-ai/sdk");
98
171
  const client = new Anthropic({ apiKey: anthropicKey });
99
- const stream = await client.messages.create({
100
- model, max_tokens: 2048, system: systemContent, messages, stream: true,
101
- });
172
+ const isClaudeModel = model.includes("claude");
173
+
174
+ const createParams = {
175
+ model, max_tokens: isClaudeModel ? 16000 : 2048,
176
+ system: systemContent, messages, stream: true,
177
+ };
178
+
179
+ // Enable extended thinking for Claude models
180
+ if (isClaudeModel) {
181
+ createParams.thinking = { type: "enabled", budget_tokens: 10000 };
182
+ }
183
+
184
+ let stream;
185
+ try {
186
+ stream = await client.messages.create(createParams);
187
+ } catch (err) {
188
+ // Fall back to regular streaming if extended thinking is unsupported
189
+ if (isClaudeModel && (err?.status === 400 || err?.status === 422)) {
190
+ delete createParams.thinking;
191
+ createParams.max_tokens = 2048;
192
+ stream = await client.messages.create(createParams);
193
+ } else {
194
+ throw err;
195
+ }
196
+ }
197
+
102
198
  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;
199
+ if (event.type === "content_block_start") {
200
+ if (event.content_block?.type === "thinking") {
201
+ display.setStatus("Reasoning");
202
+ } else if (event.content_block?.type === "text") {
203
+ display.setThinking(""); // clear thinking preview
204
+ display.setStatus("Responding");
205
+ }
206
+ }
207
+ if (event.type === "content_block_delta") {
208
+ if (event.delta?.type === "thinking_delta" && event.delta.thinking) {
209
+ display.appendThinking(event.delta.thinking);
210
+ }
211
+ if (event.delta?.type === "text_delta" && event.delta.text) {
212
+ fullText += event.delta.text;
213
+ display.setContent(fullText);
214
+ }
105
215
  }
106
216
  }
107
217
  } else if (openaiKey) {
@@ -114,13 +224,17 @@ export async function streamAssistantReply(root, messages, systemContent, opts)
114
224
  });
115
225
  for await (const chunk of stream) {
116
226
  const delta = chunk.choices?.[0]?.delta?.content;
117
- if (delta) fullText += delta;
227
+ if (delta) {
228
+ fullText += delta;
229
+ display.setStatus("Responding");
230
+ display.setContent(fullText);
231
+ }
118
232
  }
119
233
  } else {
120
234
  throw new Error("No API key (use ANTHROPIC_API_KEY, OPENAI_API_KEY, or ~/.claude auth)");
121
235
  }
122
236
  } finally {
123
- spinner.stop();
237
+ display.stop();
124
238
  }
125
239
 
126
240
  return fullText;
@@ -0,0 +1,128 @@
1
+ import fs from "node:fs";
2
+ import os from "node:os";
3
+ import path from "node:path";
4
+ import chalk from "chalk";
5
+ import inquirer from "inquirer";
6
+ import { openBrowser } from "./login.js";
7
+
8
+ const CODA_ACCOUNT_URL = "https://coda.io/account";
9
+ const FOPS_CONFIG_PATH = path.join(os.homedir(), ".fops.json");
10
+
11
+ function readFopsConfig() {
12
+ try {
13
+ if (fs.existsSync(FOPS_CONFIG_PATH)) {
14
+ return JSON.parse(fs.readFileSync(FOPS_CONFIG_PATH, "utf8"));
15
+ }
16
+ } catch {}
17
+ return {};
18
+ }
19
+
20
+ function saveFopsConfig(config) {
21
+ fs.writeFileSync(FOPS_CONFIG_PATH, JSON.stringify(config, null, 2) + "\n", { mode: 0o600 });
22
+ }
23
+
24
+ export function resolveCodaApiToken() {
25
+ const envToken = process.env.CODA_API_TOKEN?.trim();
26
+ if (envToken) return envToken;
27
+ try {
28
+ const config = readFopsConfig();
29
+ const token = config.coda?.apiToken?.trim();
30
+ if (token) return token;
31
+ } catch {}
32
+ return null;
33
+ }
34
+
35
+ function saveCodaToken(token) {
36
+ const config = readFopsConfig();
37
+ config.coda = { ...config.coda, apiToken: token.trim() };
38
+ saveFopsConfig(config);
39
+ }
40
+
41
+ async function validateCodaToken(token) {
42
+ try {
43
+ const res = await fetch("https://coda.io/apis/v1/whoami", {
44
+ headers: { Authorization: `Bearer ${token}` },
45
+ });
46
+ if (res.ok) {
47
+ const data = await res.json();
48
+ return { valid: true, name: data.name, loginId: data.loginId };
49
+ }
50
+ if (res.status === 401 || res.status === 403) {
51
+ return { valid: false, error: "Invalid or expired API token." };
52
+ }
53
+ return { valid: false, error: `Coda API returned ${res.status}.` };
54
+ } catch (err) {
55
+ return { valid: false, error: `Could not reach Coda API: ${err.message}` };
56
+ }
57
+ }
58
+
59
+ export async function runCodaLogin() {
60
+ const existing = resolveCodaApiToken();
61
+ if (existing) {
62
+ const masked = existing.slice(0, 8) + "..." + existing.slice(-4);
63
+ const { overwrite } = await inquirer.prompt([{
64
+ type: "confirm",
65
+ name: "overwrite",
66
+ message: `Already have a Coda API token (${masked}). Replace it?`,
67
+ default: false,
68
+ }]);
69
+ if (!overwrite) {
70
+ console.log(chalk.dim("Keeping existing Coda token."));
71
+ return true;
72
+ }
73
+ }
74
+
75
+ console.log("");
76
+ console.log(chalk.bold.cyan(" Coda API Token Setup"));
77
+ console.log("");
78
+ console.log(chalk.white(" To connect Foundation to Coda, you need an API token."));
79
+ console.log(chalk.white(" Here's how to get one:\n"));
80
+ console.log(chalk.dim(" 1. Go to ") + chalk.cyan(CODA_ACCOUNT_URL));
81
+ console.log(chalk.dim(" 2. Scroll to ") + chalk.white("\"API Settings\""));
82
+ console.log(chalk.dim(" 3. Click ") + chalk.white("\"Generate API token\""));
83
+ console.log(chalk.dim(" 4. Give it a name (e.g. \"Foundation CLI\")"));
84
+ console.log(chalk.dim(" 5. Copy the token and paste it below"));
85
+ console.log("");
86
+
87
+ const { openIt } = await inquirer.prompt([{
88
+ type: "confirm",
89
+ name: "openIt",
90
+ message: "Open Coda account page in your browser?",
91
+ default: true,
92
+ }]);
93
+
94
+ if (openIt) {
95
+ const opened = openBrowser(CODA_ACCOUNT_URL);
96
+ if (!opened) {
97
+ console.log(chalk.yellow("\n Could not open browser. Visit: " + CODA_ACCOUNT_URL + "\n"));
98
+ } else {
99
+ console.log("");
100
+ }
101
+ }
102
+
103
+ const { token } = await inquirer.prompt([{
104
+ type: "password",
105
+ name: "token",
106
+ message: "Paste your Coda API token:",
107
+ mask: "*",
108
+ validate: (v) => {
109
+ if (!v?.trim()) return "API token is required.";
110
+ return true;
111
+ },
112
+ }]);
113
+
114
+ console.log(chalk.dim("\n Validating token..."));
115
+ const result = await validateCodaToken(token.trim());
116
+
117
+ if (!result.valid) {
118
+ console.log(chalk.red(`\n ${result.error}`));
119
+ console.log(chalk.dim(" Check the token and try again with: fops login coda\n"));
120
+ return false;
121
+ }
122
+
123
+ saveCodaToken(token);
124
+ console.log(chalk.green(`\n Coda login successful!`));
125
+ console.log(chalk.dim(` Logged in as: ${result.name || result.loginId}`));
126
+ console.log(chalk.dim(" Token saved to ~/.fops.json\n"));
127
+ return true;
128
+ }
package/src/auth/index.js CHANGED
@@ -2,3 +2,4 @@ export { resolveAnthropicApiKey, resolveOpenAiApiKey, readJsonKey } from "./reso
2
2
  export { readClaudeCodeKeychain } from "./keychain.js";
3
3
  export { authHelp, offerClaudeLogin, runLogin } from "./login.js";
4
4
  export { runOAuthLogin } from "./oauth.js";
5
+ export { runCodaLogin, resolveCodaApiToken } from "./coda.js";
package/src/auth/login.js CHANGED
@@ -9,10 +9,10 @@ const ANTHROPIC_KEYS_URL = "https://console.anthropic.com/settings/keys";
9
9
 
10
10
  export function authHelp() {
11
11
  console.log(chalk.yellow("No API key found. Try one of:"));
12
- console.log(chalk.gray(" • Open " + chalk.cyan(ANTHROPIC_KEYS_URL) + " in your browser to sign in and create a key"));
13
- console.log(chalk.gray(" • ANTHROPIC_API_KEY or OPENAI_API_KEY environment variable"));
14
- console.log(chalk.gray(" • ~/.claude/.credentials.json with anthropic_api_key or apiKey"));
15
- console.log(chalk.gray(" • ~/.claude/settings.json with apiKeyHelper (script that prints the key)\n"));
12
+ console.log(chalk.dim(" • Open " + chalk.cyan(ANTHROPIC_KEYS_URL) + " in your browser to sign in and create a key"));
13
+ console.log(chalk.dim(" • ANTHROPIC_API_KEY or OPENAI_API_KEY environment variable"));
14
+ console.log(chalk.dim(" • ~/.claude/.credentials.json with anthropic_api_key or apiKey"));
15
+ console.log(chalk.dim(" • ~/.claude/settings.json with apiKeyHelper (script that prints the key)\n"));
16
16
  }
17
17
 
18
18
  export function openBrowser(url) {
@@ -46,7 +46,7 @@ export function saveApiKey(apiKey) {
46
46
  fs.writeFileSync(CLAUDE_CREDENTIALS, JSON.stringify(creds, null, 2) + "\n", { mode: 0o600 });
47
47
 
48
48
  console.log(chalk.green("\nLogin successful"));
49
- console.log(chalk.gray("Key saved to ~/.claude/.credentials.json\n"));
49
+ console.log(chalk.dim("Key saved to ~/.claude/.credentials.json\n"));
50
50
  return true;
51
51
  }
52
52
 
@@ -65,11 +65,11 @@ export async function offerClaudeLogin() {
65
65
  if (!opened) {
66
66
  console.log(chalk.yellow(" Could not open browser. Visit: " + ANTHROPIC_KEYS_URL + "\n"));
67
67
  }
68
- console.log(chalk.gray(" 1. Sign in and create an API key"));
69
- console.log(chalk.gray(" 2. Add it to ~/.claude/.credentials.json:"));
70
- console.log(chalk.gray(' { "anthropic_api_key": "sk-ant-..." }'));
71
- console.log(chalk.gray(" 3. Or run: export ANTHROPIC_API_KEY=\"sk-ant-...\""));
72
- console.log(chalk.gray(" 4. Then run foundation chat again.\n"));
68
+ console.log(chalk.dim(" 1. Sign in and create an API key"));
69
+ console.log(chalk.dim(" 2. Add it to ~/.claude/.credentials.json:"));
70
+ console.log(chalk.dim(' { "anthropic_api_key": "sk-ant-..." }'));
71
+ console.log(chalk.dim(" 3. Or run: export ANTHROPIC_API_KEY=\"sk-ant-...\""));
72
+ console.log(chalk.dim(" 4. Then run foundation chat again.\n"));
73
73
  return true;
74
74
  }
75
75
 
@@ -310,7 +310,7 @@ export async function runLogin(options = {}) {
310
310
  },
311
311
  ]);
312
312
  if (!overwrite) {
313
- console.log(chalk.gray("Keeping existing credentials."));
313
+ console.log(chalk.dim("Keeping existing credentials."));
314
314
  return true;
315
315
  }
316
316
  }
@@ -405,8 +405,8 @@ async function runDeviceLogin() {
405
405
  const url = `http://127.0.0.1:${port}`;
406
406
 
407
407
  console.log(chalk.blue("\nOpening browser for authentication...\n"));
408
- console.log(chalk.gray(` If browser doesn't open, visit: ${chalk.cyan(url)}\n`));
409
- console.log(chalk.gray(" Waiting for authentication..."));
408
+ console.log(chalk.dim(` If browser doesn't open, visit: ${chalk.cyan(url)}\n`));
409
+ console.log(chalk.dim(" Waiting for authentication..."));
410
410
 
411
411
  openBrowser(url);
412
412
  });
package/src/auth/oauth.js CHANGED
@@ -153,9 +153,9 @@ export async function runOAuthLogin() {
153
153
 
154
154
  console.log(chalk.green("\nLogin successful"));
155
155
  if (process.platform === "darwin") {
156
- console.log(chalk.gray("Token saved to macOS Keychain\n"));
156
+ console.log(chalk.dim("Token saved to macOS Keychain\n"));
157
157
  } else {
158
- console.log(chalk.gray("Token saved to ~/.claude/.credentials.json\n"));
158
+ console.log(chalk.dim("Token saved to ~/.claude/.credentials.json\n"));
159
159
  }
160
160
 
161
161
  server.close();
@@ -187,8 +187,8 @@ export async function runOAuthLogin() {
187
187
  authUrl.searchParams.set("code_challenge_method", "S256");
188
188
 
189
189
  console.log(chalk.blue("\nOpening browser for Claude login...\n"));
190
- console.log(chalk.gray(` If browser doesn't open, visit:\n ${chalk.cyan(authUrl.toString())}\n`));
191
- console.log(chalk.gray(" Waiting for authentication..."));
190
+ console.log(chalk.dim(` If browser doesn't open, visit:\n ${chalk.cyan(authUrl.toString())}\n`));
191
+ console.log(chalk.dim(" Waiting for authentication..."));
192
192
 
193
193
  openBrowser(authUrl.toString());
194
194
  });
@@ -4,13 +4,15 @@ import path from "node:path";
4
4
  import chalk from "chalk";
5
5
  import { Command } from "commander";
6
6
  import { PKG } from "../config.js";
7
- import { rootDir, requireRoot, hasComposeInDir, isFoundationRoot, findComposeRootUp } from "../project.js";
8
- import { make } from "../shell.js";
7
+ import { rootDir, requireRoot, hasComposeInDir, isFoundationRoot, findComposeRootUp, checkInitState } from "../project.js";
8
+ import { execa } from "execa";
9
+ import { make, dockerCompose } from "../shell.js";
9
10
  import { runSetup, runInitWizard } from "../setup/index.js";
10
11
  import { ensureEcrAuth } from "../setup/aws.js";
11
12
  import { runAgentSingleTurn, runAgentInteractive } from "../agent/index.js";
12
13
  import { runDoctor } from "../doctor.js";
13
- import { runLogin } from "../auth/index.js";
14
+ import { runFeatureFlags } from "../feature-flags.js";
15
+ import { runLogin, runCodaLogin } from "../auth/index.js";
14
16
  import { runHook, loadSkills } from "../plugins/index.js";
15
17
 
16
18
  export function registerCommands(program, registry) {
@@ -18,10 +20,19 @@ export function registerCommands(program, registry) {
18
20
 
19
21
  program
20
22
  .command("login")
21
- .description("Authenticate with Claude (OAuth login via browser)")
23
+ .description("Authenticate with services (Claude, Coda)")
24
+ .argument("[service]", "Service to login to: claude (default) or coda")
22
25
  .option("--no-browser", "Paste API key in terminal instead of OAuth")
23
- .action(async (opts) => {
24
- await runLogin({ browser: opts.browser });
26
+ .action(async (service, opts) => {
27
+ const target = (service || "claude").toLowerCase();
28
+ if (target === "coda") {
29
+ await runCodaLogin();
30
+ } else if (target === "claude") {
31
+ await runLogin({ browser: opts.browser });
32
+ } else {
33
+ console.error(chalk.red(`Unknown service "${target}". Use: claude, coda`));
34
+ process.exit(1);
35
+ }
25
36
  });
26
37
 
27
38
  program
@@ -82,9 +93,9 @@ export function registerCommands(program, registry) {
82
93
  .action(async (opts) => {
83
94
  const root = requireRoot(program);
84
95
  if (opts.message) {
85
- await runAgentSingleTurn(root, opts.message, { runSuggestions: opts.run !== false, model: opts.model });
96
+ await runAgentSingleTurn(root, opts.message, { runSuggestions: opts.run !== false, model: opts.model, registry });
86
97
  } else {
87
- await runAgentInteractive(root);
98
+ await runAgentInteractive(root, { registry });
88
99
  }
89
100
  });
90
101
 
@@ -95,11 +106,55 @@ export function registerCommands(program, registry) {
95
106
  .option("--no-chat", "Skip interactive AI assistant after startup")
96
107
  .action(async (opts) => {
97
108
  const root = requireRoot(program);
109
+
110
+ // Pre-flight: check if project is initialised
111
+ const initIssue = checkInitState(root);
112
+ if (initIssue) {
113
+ console.error(chalk.red(`\n Project not ready: ${initIssue}.`));
114
+ console.error(chalk.dim(" Run `fops init` first to set up the project.\n"));
115
+ process.exit(1);
116
+ }
117
+
98
118
  await ensureEcrAuth(root);
99
119
  await runHook(registry, "before:up", { root });
100
- await make(root, "start");
120
+
121
+ // Detect stuck containers (restarting / unhealthy) and force-recreate them
122
+ const forceRecreate = [];
123
+ try {
124
+ const { stdout } = await execa("docker", ["compose", "ps", "--format", "json"], {
125
+ cwd: root, reject: false, timeout: 10000,
126
+ });
127
+ if (stdout?.trim()) {
128
+ for (const line of stdout.trim().split("\n").filter(Boolean)) {
129
+ try {
130
+ const svc = JSON.parse(line);
131
+ const state = (svc.State || "").toLowerCase();
132
+ const health = (svc.Health || "").toLowerCase();
133
+ if (state === "restarting" || health === "unhealthy") {
134
+ forceRecreate.push(svc.Service || svc.Name);
135
+ }
136
+ } catch {}
137
+ }
138
+ }
139
+ } catch {}
140
+
141
+ if (forceRecreate.length > 0) {
142
+ console.log(chalk.yellow(` Recreating stuck containers: ${forceRecreate.join(", ")}`));
143
+ await dockerCompose(root, ["rm", "-f", "-s", ...forceRecreate]);
144
+ }
145
+
146
+ console.log(chalk.green(" Starting services..."));
147
+ const result = await dockerCompose(root, ["up", "-d", "--remove-orphans", "--pull", "always"]);
148
+ // Clear any trailing \r progress line from docker compose output
149
+ process.stdout.write("\x1b[2K\r");
101
150
  await runHook(registry, "after:up", { root });
102
- if (opts.chat !== false) await runAgentInteractive(root);
151
+ if (result.exitCode !== 0) {
152
+ console.error(chalk.red(`\n Some services failed to start (exit code ${result.exitCode}).`));
153
+ console.error(chalk.dim(" Dropping into debug agent to diagnose...\n"));
154
+ await runAgentInteractive(root, { registry, initialAgent: "debug" });
155
+ return;
156
+ }
157
+ if (opts.chat !== false) await runAgentInteractive(root, { registry });
103
158
  });
104
159
 
105
160
  program
@@ -107,7 +162,7 @@ export function registerCommands(program, registry) {
107
162
  .description("Interactive AI assistant (same as foundation agent with no -m)")
108
163
  .action(async () => {
109
164
  const root = requireRoot(program);
110
- await runAgentInteractive(root);
165
+ await runAgentInteractive(root, { registry });
111
166
  });
112
167
 
113
168
  program
@@ -150,10 +205,28 @@ export function registerCommands(program, registry) {
150
205
 
151
206
  program
152
207
  .command("config")
153
- .description("Launch interactive configuration (make config)")
208
+ .description("Toggle MX_FF_* feature flags and restart affected services")
209
+ .action(async () => {
210
+ const root = requireRoot(program);
211
+ await runFeatureFlags(root);
212
+ });
213
+
214
+ program
215
+ .command("build")
216
+ .description("Build all Foundation service images from source")
154
217
  .action(async () => {
155
218
  const root = requireRoot(program);
156
- await make(root, "config");
219
+ await ensureEcrAuth(root);
220
+ await make(root, "build");
221
+ });
222
+
223
+ program
224
+ .command("download")
225
+ .description("Pull all container images from registry (requires ECR auth)")
226
+ .action(async () => {
227
+ const root = requireRoot(program);
228
+ await ensureEcrAuth(root);
229
+ await make(root, "download");
157
230
  });
158
231
 
159
232
  program
@@ -183,14 +256,14 @@ export function registerCommands(program, registry) {
183
256
  .action(async () => {
184
257
  const skills = await loadSkills(registry);
185
258
  if (skills.length === 0) {
186
- console.log(chalk.gray(" No skills available."));
259
+ console.log(chalk.dim(" No skills available."));
187
260
  return;
188
261
  }
189
262
  console.log(chalk.bold.cyan("\n Agent Skills\n"));
190
263
  for (const s of skills) {
191
- const source = s.pluginId ? chalk.gray(`(plugin: ${s.pluginId})`) : chalk.gray("(built-in)");
264
+ const source = s.pluginId ? chalk.dim(`(plugin: ${s.pluginId})`) : chalk.dim("(built-in)");
192
265
  console.log(` ${chalk.green("●")} ${chalk.bold(s.name)} ${source}`);
193
- if (s.description) console.log(chalk.gray(` ${s.description}`));
266
+ if (s.description) console.log(chalk.dim(` ${s.description}`));
194
267
  }
195
268
  console.log("");
196
269
  });
@@ -205,15 +278,15 @@ export function registerCommands(program, registry) {
205
278
  .description("List installed plugins with status")
206
279
  .action(async () => {
207
280
  if (registry.plugins.length === 0) {
208
- console.log(chalk.gray(" No plugins installed."));
209
- console.log(chalk.gray(" Install plugins to ~/.fops/plugins/ or via npm (fops-plugin-*)."));
281
+ console.log(chalk.dim(" No plugins installed."));
282
+ console.log(chalk.dim(" Install plugins to ~/.fops/plugins/ or via npm (fops-plugin-*)."));
210
283
  return;
211
284
  }
212
285
  console.log(chalk.bold.cyan("\n Installed Plugins\n"));
213
286
  for (const p of registry.plugins) {
214
- const source = chalk.gray(`(${p.source})`);
215
- console.log(` ${chalk.green("●")} ${chalk.bold(p.name)} ${chalk.gray("v" + p.version)} ${source}`);
216
- console.log(chalk.gray(` id: ${p.id} path: ${p.path}`));
287
+ const source = chalk.dim(`(${p.source})`);
288
+ console.log(` ${chalk.green("●")} ${chalk.bold(p.name)} ${chalk.dim("v" + p.version)} ${source}`);
289
+ console.log(chalk.dim(` id: ${p.id} path: ${p.path}`));
217
290
  }
218
291
  console.log("");
219
292
  });
package/src/config.js CHANGED
@@ -18,7 +18,7 @@ export const CLI_BRAND = {
18
18
  export function printFoundationBanner(cwd) {
19
19
  const cwdShort = cwd.replace(os.homedir(), "~");
20
20
  console.log(chalk.cyan(` ${CLI_BRAND.title} ${CLI_BRAND.version}`));
21
- console.log(chalk.gray(` ${CLI_BRAND.byline}`));
22
- console.log(chalk.gray(` ${cwdShort}`));
21
+ console.log(chalk.dim(` ${CLI_BRAND.byline}`));
22
+ console.log(chalk.dim(` ${cwdShort}`));
23
23
  console.log("");
24
24
  }