@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.
- package/README.md +62 -40
- package/package.json +4 -3
- package/src/agent/agent.js +161 -68
- package/src/agent/agents.js +224 -0
- package/src/agent/context.js +287 -96
- package/src/agent/index.js +1 -0
- package/src/agent/llm.js +134 -20
- package/src/auth/coda.js +128 -0
- package/src/auth/index.js +1 -0
- package/src/auth/login.js +13 -13
- package/src/auth/oauth.js +4 -4
- package/src/commands/index.js +94 -21
- package/src/config.js +2 -2
- package/src/doctor.js +208 -22
- package/src/feature-flags.js +197 -0
- package/src/plugins/api.js +23 -0
- package/src/plugins/builtins/stack-api.js +36 -0
- package/src/plugins/index.js +1 -0
- package/src/plugins/knowledge.js +124 -0
- package/src/plugins/loader.js +67 -0
- package/src/plugins/registry.js +3 -0
- package/src/project.js +20 -1
- package/src/setup/aws.js +7 -7
- package/src/setup/setup.js +18 -12
- package/src/setup/wizard.js +86 -15
- package/src/shell.js +2 -2
- package/src/skills/foundation/SKILL.md +200 -66
- package/src/ui/confirm.js +3 -2
- package/src/ui/input.js +31 -34
- package/src/ui/spinner.js +39 -13
- package/src/ui/streaming.js +2 -2
- package/STRUCTURE.md +0 -43
- package/src/agent/agent.test.js +0 -233
- package/src/agent/context.test.js +0 -81
- package/src/agent/llm.test.js +0 -139
- package/src/auth/keychain.test.js +0 -185
- package/src/auth/login.test.js +0 -192
- package/src/auth/oauth.test.js +0 -118
- package/src/auth/resolve.test.js +0 -153
- package/src/config.test.js +0 -70
- package/src/doctor.test.js +0 -134
- package/src/plugins/api.test.js +0 -95
- package/src/plugins/discovery.test.js +0 -92
- package/src/plugins/hooks.test.js +0 -118
- package/src/plugins/manifest.test.js +0 -106
- package/src/plugins/registry.test.js +0 -43
- package/src/plugins/skills.test.js +0 -173
- package/src/project.test.js +0 -196
- package/src/setup/aws.test.js +0 -280
- package/src/shell.test.js +0 -72
- package/src/ui/banner.test.js +0 -97
- 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 {
|
|
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 = [
|
|
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
|
-
|
|
74
|
-
|
|
75
|
-
|
|
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
|
|
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(
|
|
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
|
|
100
|
-
|
|
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 === "
|
|
104
|
-
|
|
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)
|
|
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
|
-
|
|
237
|
+
display.stop();
|
|
124
238
|
}
|
|
125
239
|
|
|
126
240
|
return fullText;
|
package/src/auth/coda.js
ADDED
|
@@ -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.
|
|
13
|
-
console.log(chalk.
|
|
14
|
-
console.log(chalk.
|
|
15
|
-
console.log(chalk.
|
|
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.
|
|
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.
|
|
69
|
-
console.log(chalk.
|
|
70
|
-
console.log(chalk.
|
|
71
|
-
console.log(chalk.
|
|
72
|
-
console.log(chalk.
|
|
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.
|
|
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.
|
|
409
|
-
console.log(chalk.
|
|
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.
|
|
156
|
+
console.log(chalk.dim("Token saved to macOS Keychain\n"));
|
|
157
157
|
} else {
|
|
158
|
-
console.log(chalk.
|
|
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.
|
|
191
|
-
console.log(chalk.
|
|
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
|
});
|
package/src/commands/index.js
CHANGED
|
@@ -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 {
|
|
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 {
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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 (
|
|
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("
|
|
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
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
209
|
-
console.log(chalk.
|
|
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.
|
|
215
|
-
console.log(` ${chalk.green("●")} ${chalk.bold(p.name)} ${chalk.
|
|
216
|
-
console.log(chalk.
|
|
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.
|
|
22
|
-
console.log(chalk.
|
|
21
|
+
console.log(chalk.dim(` ${CLI_BRAND.byline}`));
|
|
22
|
+
console.log(chalk.dim(` ${cwdShort}`));
|
|
23
23
|
console.log("");
|
|
24
24
|
}
|