@meshxdata/fops 0.0.3 → 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/package.json +1 -1
- package/src/agent/agent.js +139 -38
- package/src/agent/agents.js +224 -0
- package/src/agent/context.js +146 -12
- package/src/agent/index.js +1 -0
- package/src/agent/llm.js +84 -13
- package/src/auth/coda.js +10 -10
- package/src/auth/login.js +13 -13
- package/src/auth/oauth.js +4 -4
- package/src/commands/index.js +74 -15
- package/src/config.js +2 -2
- package/src/doctor.js +67 -9
- package/src/feature-flags.js +197 -0
- package/src/plugins/api.js +14 -0
- package/src/plugins/builtins/stack-api.js +36 -0
- package/src/plugins/loader.js +67 -0
- package/src/plugins/registry.js +2 -0
- package/src/project.js +20 -1
- package/src/setup/aws.js +7 -7
- package/src/setup/setup.js +10 -9
- package/src/setup/wizard.js +73 -6
- package/src/ui/confirm.js +3 -2
- package/src/ui/input.js +2 -2
- package/src/ui/spinner.js +4 -4
- package/src/ui/streaming.js +2 -2
package/src/agent/context.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import fs from "node:fs";
|
|
2
|
+
import http from "node:http";
|
|
2
3
|
import path from "node:path";
|
|
3
4
|
import { execa } from "execa";
|
|
4
5
|
import { loadSkills, searchKnowledge } from "../plugins/index.js";
|
|
@@ -17,8 +18,23 @@ When suggesting commands, ALWAYS use \`fops\` commands, not raw \`make\` or \`gi
|
|
|
17
18
|
**Always suggest 2–3 commands** so the user can pick. Pair a primary action with a follow-up (e.g. restart + logs, doctor + up).
|
|
18
19
|
|
|
19
20
|
## Accuracy Rules
|
|
20
|
-
- ALWAYS check
|
|
21
|
-
-
|
|
21
|
+
- ALWAYS check BOTH container status AND service health context. Container "running (healthy)" only means the Docker healthcheck passed — the service may still be initializing, have failed migrations, or be unresponsive.
|
|
22
|
+
- Cross-reference the "Service health" section (HTTP endpoint checks) with container status. If any endpoint is DOWN or unreachable, the stack is NOT fully ready — report this even if containers look healthy.
|
|
23
|
+
- If ANY container is exited, unhealthy, or failed, report it — never claim "all healthy" when failures exist.
|
|
24
|
+
- When containers have failed or services are unreachable, lead with the failures and suggest diagnostics.
|
|
25
|
+
- If "Missing images" context is present, report which images are missing and whether they need building or pulling. Suggest \`fops build\` for buildable images or \`make download\` (after ECR auth) for registry images.
|
|
26
|
+
|
|
27
|
+
## Auto-Fix Rules
|
|
28
|
+
When you detect failing containers, DO NOT just report them — diagnose and fix:
|
|
29
|
+
1. Read the container logs provided in context. Look for: missing files/volumes, permission errors, config issues, dependency failures, image problems.
|
|
30
|
+
2. Apply the RIGHT fix based on the diagnosis:
|
|
31
|
+
- **Restarting/crash-loop with no logs**: likely a missing volume mount or stale image → suggest \`fops build\` then \`docker compose up -d <service>\`
|
|
32
|
+
- **Image not found / pull access denied**: missing image → \`fops build\` (for buildable) or \`fops download\` (for registry images)
|
|
33
|
+
- **Dependency unhealthy**: fix the dependency first, then restart dependents → \`docker compose up -d <dep-service>\`
|
|
34
|
+
- **Port conflict**: another process using the port → identify and kill or change port in .env
|
|
35
|
+
- **Migration failed**: database issue → \`docker compose restart <service>-migrations\`
|
|
36
|
+
- **Config error / env missing**: check .env file → \`fops setup\`
|
|
37
|
+
3. Output the fix commands in fenced bash blocks so they auto-execute. Be decisive — don't hedge with "you might try", just say what to do and give the command.
|
|
22
38
|
|
|
23
39
|
## Security Rules
|
|
24
40
|
- Never output API keys, passwords, or tokens in responses
|
|
@@ -42,8 +58,10 @@ async function gatherDockerStatus(root) {
|
|
|
42
58
|
if (!parsed.length) return "No containers running.";
|
|
43
59
|
|
|
44
60
|
let healthy = 0, unhealthy = 0, exited = 0, running = 0;
|
|
61
|
+
const failingContainers = [];
|
|
45
62
|
const services = parsed.map((o) => {
|
|
46
63
|
const name = o.Name || o.name || o.Service || "?";
|
|
64
|
+
const service = o.Service || name;
|
|
47
65
|
const state = (o.State || "").toLowerCase();
|
|
48
66
|
const status = o.Status || "";
|
|
49
67
|
const health = (o.Health || "").toLowerCase();
|
|
@@ -51,11 +69,13 @@ async function gatherDockerStatus(root) {
|
|
|
51
69
|
|
|
52
70
|
if (state === "exited" || state === "dead") {
|
|
53
71
|
exited++;
|
|
72
|
+
failingContainers.push(service);
|
|
54
73
|
return `${name}: EXITED (code ${exitCode}) — ${status}`;
|
|
55
74
|
}
|
|
56
|
-
if (health === "unhealthy") {
|
|
75
|
+
if (health === "unhealthy" || state === "restarting") {
|
|
57
76
|
unhealthy++;
|
|
58
|
-
|
|
77
|
+
failingContainers.push(service);
|
|
78
|
+
return `${name}: ${state === "restarting" ? "RESTARTING" : "UNHEALTHY"} — ${status}`;
|
|
59
79
|
}
|
|
60
80
|
if (state === "running" && (health === "healthy" || !health)) {
|
|
61
81
|
healthy++;
|
|
@@ -69,15 +89,36 @@ async function gatherDockerStatus(root) {
|
|
|
69
89
|
const summary = [];
|
|
70
90
|
if (running) summary.push(`${running} running`);
|
|
71
91
|
if (healthy) summary.push(`${healthy} healthy`);
|
|
72
|
-
if (unhealthy) summary.push(`${unhealthy} UNHEALTHY`);
|
|
92
|
+
if (unhealthy) summary.push(`${unhealthy} UNHEALTHY/RESTARTING`);
|
|
73
93
|
if (exited) summary.push(`${exited} EXITED/FAILED`);
|
|
74
94
|
|
|
75
95
|
let header = `Container summary: ${parsed.length} total — ${summary.join(", ")}`;
|
|
76
96
|
if (unhealthy || exited) {
|
|
77
|
-
header += "\n⚠ ATTENTION: Some containers are failing. Diagnose and
|
|
97
|
+
header += "\n⚠ ATTENTION: Some containers are failing. Diagnose and fix the failures.";
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
let result = header + "\n\nContainer details:\n" + services.join("\n");
|
|
101
|
+
|
|
102
|
+
// Auto-collect logs from failing containers (last 15 lines each, max 3)
|
|
103
|
+
if (failingContainers.length > 0) {
|
|
104
|
+
const logsToFetch = failingContainers.slice(0, 3);
|
|
105
|
+
const logResults = await Promise.all(
|
|
106
|
+
logsToFetch.map(async (svc) => {
|
|
107
|
+
try {
|
|
108
|
+
const { stdout, stderr } = await execa(
|
|
109
|
+
"docker", ["compose", "logs", svc, "--tail", "15", "--no-color"],
|
|
110
|
+
{ cwd: root, reject: false, timeout: 5000 },
|
|
111
|
+
);
|
|
112
|
+
const output = (stdout || "") + (stderr || "");
|
|
113
|
+
if (output.trim()) return `\n--- Logs: ${svc} (last 15 lines) ---\n${output.trim()}`;
|
|
114
|
+
} catch { /* skip */ }
|
|
115
|
+
return `\n--- Logs: ${svc} ---\n(no logs available)`;
|
|
116
|
+
}),
|
|
117
|
+
);
|
|
118
|
+
result += "\n" + logResults.join("\n");
|
|
78
119
|
}
|
|
79
120
|
|
|
80
|
-
return
|
|
121
|
+
return result;
|
|
81
122
|
}
|
|
82
123
|
} catch {
|
|
83
124
|
return "Docker: not available or not running.";
|
|
@@ -85,6 +126,95 @@ async function gatherDockerStatus(root) {
|
|
|
85
126
|
return null;
|
|
86
127
|
}
|
|
87
128
|
|
|
129
|
+
/**
|
|
130
|
+
* HTTP GET with timeout. Returns { ok, status } or { ok: false, error }.
|
|
131
|
+
*/
|
|
132
|
+
function httpPing(url, timeout = 3000) {
|
|
133
|
+
return new Promise((resolve) => {
|
|
134
|
+
const req = http.get(url, { timeout }, (res) => {
|
|
135
|
+
res.resume(); // drain
|
|
136
|
+
resolve({ ok: res.statusCode < 500, status: res.statusCode });
|
|
137
|
+
});
|
|
138
|
+
req.on("error", (err) => resolve({ ok: false, error: err.code || err.message }));
|
|
139
|
+
req.on("timeout", () => { req.destroy(); resolve({ ok: false, error: "TIMEOUT" }); });
|
|
140
|
+
});
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
const SERVICE_ENDPOINTS = [
|
|
144
|
+
{ name: "Backend API", url: "http://localhost:9001/api/data/mesh/list?per_page=1", port: 9001 },
|
|
145
|
+
{ name: "Frontend", url: "http://localhost:3002", port: 3002 },
|
|
146
|
+
{ name: "Storage Engine (MinIO)", url: "http://localhost:9002/minio/health/live", port: 9002 },
|
|
147
|
+
{ name: "Trino", url: "http://localhost:8081/v1/info", port: 8081 },
|
|
148
|
+
];
|
|
149
|
+
|
|
150
|
+
async function gatherServiceHealth() {
|
|
151
|
+
const results = await Promise.all(
|
|
152
|
+
SERVICE_ENDPOINTS.map(async ({ name, url }) => {
|
|
153
|
+
const r = await httpPing(url);
|
|
154
|
+
if (r.ok) return `${name}: UP (HTTP ${r.status})`;
|
|
155
|
+
return `${name}: DOWN (${r.error || "HTTP " + r.status})`;
|
|
156
|
+
}),
|
|
157
|
+
);
|
|
158
|
+
return "Service health (HTTP checks):\n" + results.join("\n");
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* Detect which compose images are missing locally.
|
|
163
|
+
* Compares `docker compose config --images` against `docker images`.
|
|
164
|
+
*/
|
|
165
|
+
async function gatherMissingImages(root) {
|
|
166
|
+
try {
|
|
167
|
+
// Get all images required by compose
|
|
168
|
+
const { stdout: configImages } = await execa(
|
|
169
|
+
"docker", ["compose", "config", "--images"],
|
|
170
|
+
{ cwd: root, reject: false, timeout: 10000 },
|
|
171
|
+
);
|
|
172
|
+
if (!configImages?.trim()) return null;
|
|
173
|
+
|
|
174
|
+
const required = [...new Set(configImages.trim().split("\n").filter(Boolean))];
|
|
175
|
+
|
|
176
|
+
// Get locally available images
|
|
177
|
+
const { stdout: localImages } = await execa(
|
|
178
|
+
"docker", ["images", "--format", "{{.Repository}}:{{.Tag}}"],
|
|
179
|
+
{ reject: false, timeout: 5000 },
|
|
180
|
+
);
|
|
181
|
+
const localSet = new Set((localImages || "").trim().split("\n").filter(Boolean));
|
|
182
|
+
|
|
183
|
+
// Also check by repo without tag (docker sometimes lists <none> tag)
|
|
184
|
+
const localRepos = new Set(
|
|
185
|
+
(localImages || "").trim().split("\n").filter(Boolean).map((i) => i.split(":")[0]),
|
|
186
|
+
);
|
|
187
|
+
|
|
188
|
+
// Find which compose services have build contexts
|
|
189
|
+
const { stdout: configJson } = await execa(
|
|
190
|
+
"docker", ["compose", "config", "--format", "json"],
|
|
191
|
+
{ cwd: root, reject: false, timeout: 10000 },
|
|
192
|
+
);
|
|
193
|
+
const buildableImages = new Set();
|
|
194
|
+
if (configJson?.trim()) {
|
|
195
|
+
try {
|
|
196
|
+
const config = JSON.parse(configJson);
|
|
197
|
+
for (const [, svc] of Object.entries(config.services || {})) {
|
|
198
|
+
if (svc.build && svc.image) buildableImages.add(svc.image);
|
|
199
|
+
}
|
|
200
|
+
} catch { /* ignore */ }
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
const missing = required.filter((img) => !localSet.has(img) && !localRepos.has(img.split(":")[0]));
|
|
204
|
+
if (missing.length === 0) return null;
|
|
205
|
+
|
|
206
|
+
const lines = missing.map((img) => {
|
|
207
|
+
const action = buildableImages.has(img) ? "needs BUILD (has build context)" : "needs PULL from registry";
|
|
208
|
+
return ` ${img} — ${action}`;
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
return `Missing images (${missing.length}/${required.length}):\n` + lines.join("\n") +
|
|
212
|
+
"\n\nTo build local images: make build\nTo pull registry images: make download (requires ECR auth)";
|
|
213
|
+
} catch {
|
|
214
|
+
return null;
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
|
|
88
218
|
async function gatherImageAges(root) {
|
|
89
219
|
try {
|
|
90
220
|
const { stdout: imgOut } = await execa("docker", ["compose", "images", "--format", "json"], { cwd: root, reject: false, timeout: 5000 });
|
|
@@ -152,9 +282,9 @@ function checkEnvFile(root) {
|
|
|
152
282
|
return null;
|
|
153
283
|
}
|
|
154
284
|
|
|
155
|
-
async function gatherSkills() {
|
|
285
|
+
async function gatherSkills(registry) {
|
|
156
286
|
try {
|
|
157
|
-
const skills = await loadSkills();
|
|
287
|
+
const skills = await loadSkills(registry);
|
|
158
288
|
if (skills.length) {
|
|
159
289
|
return "## Additional Skills\n" + skills.map((s) => s.content).join("\n\n");
|
|
160
290
|
}
|
|
@@ -169,16 +299,20 @@ export async function gatherStackContext(root, { registry, message } = {}) {
|
|
|
169
299
|
|
|
170
300
|
if (root) {
|
|
171
301
|
// Run all independent checks in parallel
|
|
172
|
-
const [dockerStatus, imageAges, prereqs, envInfo, skills, knowledge] = await Promise.all([
|
|
302
|
+
const [dockerStatus, serviceHealth, missingImages, imageAges, prereqs, envInfo, skills, knowledge] = await Promise.all([
|
|
173
303
|
gatherDockerStatus(root),
|
|
304
|
+
gatherServiceHealth(),
|
|
305
|
+
gatherMissingImages(root),
|
|
174
306
|
gatherImageAges(root),
|
|
175
307
|
gatherPrereqs(),
|
|
176
308
|
Promise.resolve(checkEnvFile(root)),
|
|
177
|
-
gatherSkills(),
|
|
309
|
+
gatherSkills(registry),
|
|
178
310
|
registry && message ? searchKnowledge(registry, message) : Promise.resolve(null),
|
|
179
311
|
]);
|
|
180
312
|
|
|
181
313
|
if (dockerStatus) parts.push(dockerStatus);
|
|
314
|
+
if (serviceHealth) parts.push(serviceHealth);
|
|
315
|
+
if (missingImages) parts.push(missingImages);
|
|
182
316
|
if (imageAges) parts.push(imageAges);
|
|
183
317
|
parts.push(prereqs);
|
|
184
318
|
if (envInfo) parts.push(envInfo);
|
|
@@ -188,7 +322,7 @@ export async function gatherStackContext(root, { registry, message } = {}) {
|
|
|
188
322
|
// No root — still check prereqs, skills, and knowledge
|
|
189
323
|
const [prereqs, skills, knowledge] = await Promise.all([
|
|
190
324
|
gatherPrereqs(),
|
|
191
|
-
gatherSkills(),
|
|
325
|
+
gatherSkills(registry),
|
|
192
326
|
registry && message ? searchKnowledge(registry, message) : Promise.resolve(null),
|
|
193
327
|
]);
|
|
194
328
|
parts.push(prereqs);
|
package/src/agent/index.js
CHANGED
package/src/agent/llm.js
CHANGED
|
@@ -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
|
|
|
@@ -91,10 +147,25 @@ export async function streamAssistantReply(root, messages, systemContent, opts)
|
|
|
91
147
|
try {
|
|
92
148
|
if (useClaudeCode) {
|
|
93
149
|
const prompt = buildConversationPrompt(messages);
|
|
94
|
-
fullText = await streamViaClaudeCode(
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
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
|
+
);
|
|
98
169
|
} else if (anthropicKey) {
|
|
99
170
|
const { default: Anthropic } = await import("@anthropic-ai/sdk");
|
|
100
171
|
const client = new Anthropic({ apiKey: anthropicKey });
|
package/src/auth/coda.js
CHANGED
|
@@ -67,7 +67,7 @@ export async function runCodaLogin() {
|
|
|
67
67
|
default: false,
|
|
68
68
|
}]);
|
|
69
69
|
if (!overwrite) {
|
|
70
|
-
console.log(chalk.
|
|
70
|
+
console.log(chalk.dim("Keeping existing Coda token."));
|
|
71
71
|
return true;
|
|
72
72
|
}
|
|
73
73
|
}
|
|
@@ -77,11 +77,11 @@ export async function runCodaLogin() {
|
|
|
77
77
|
console.log("");
|
|
78
78
|
console.log(chalk.white(" To connect Foundation to Coda, you need an API token."));
|
|
79
79
|
console.log(chalk.white(" Here's how to get one:\n"));
|
|
80
|
-
console.log(chalk.
|
|
81
|
-
console.log(chalk.
|
|
82
|
-
console.log(chalk.
|
|
83
|
-
console.log(chalk.
|
|
84
|
-
console.log(chalk.
|
|
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
85
|
console.log("");
|
|
86
86
|
|
|
87
87
|
const { openIt } = await inquirer.prompt([{
|
|
@@ -111,18 +111,18 @@ export async function runCodaLogin() {
|
|
|
111
111
|
},
|
|
112
112
|
}]);
|
|
113
113
|
|
|
114
|
-
console.log(chalk.
|
|
114
|
+
console.log(chalk.dim("\n Validating token..."));
|
|
115
115
|
const result = await validateCodaToken(token.trim());
|
|
116
116
|
|
|
117
117
|
if (!result.valid) {
|
|
118
118
|
console.log(chalk.red(`\n ${result.error}`));
|
|
119
|
-
console.log(chalk.
|
|
119
|
+
console.log(chalk.dim(" Check the token and try again with: fops login coda\n"));
|
|
120
120
|
return false;
|
|
121
121
|
}
|
|
122
122
|
|
|
123
123
|
saveCodaToken(token);
|
|
124
124
|
console.log(chalk.green(`\n Coda login successful!`));
|
|
125
|
-
console.log(chalk.
|
|
126
|
-
console.log(chalk.
|
|
125
|
+
console.log(chalk.dim(` Logged in as: ${result.name || result.loginId}`));
|
|
126
|
+
console.log(chalk.dim(" Token saved to ~/.fops.json\n"));
|
|
127
127
|
return true;
|
|
128
128
|
}
|
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,12 +4,14 @@ 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";
|
|
14
|
+
import { runFeatureFlags } from "../feature-flags.js";
|
|
13
15
|
import { runLogin, runCodaLogin } from "../auth/index.js";
|
|
14
16
|
import { runHook, loadSkills } from "../plugins/index.js";
|
|
15
17
|
|
|
@@ -104,14 +106,53 @@ export function registerCommands(program, registry) {
|
|
|
104
106
|
.option("--no-chat", "Skip interactive AI assistant after startup")
|
|
105
107
|
.action(async (opts) => {
|
|
106
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
|
+
|
|
107
118
|
await ensureEcrAuth(root);
|
|
108
119
|
await runHook(registry, "before:up", { root });
|
|
109
|
-
|
|
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");
|
|
110
150
|
await runHook(registry, "after:up", { root });
|
|
111
151
|
if (result.exitCode !== 0) {
|
|
112
152
|
console.error(chalk.red(`\n Some services failed to start (exit code ${result.exitCode}).`));
|
|
113
|
-
console.error(chalk.
|
|
114
|
-
|
|
153
|
+
console.error(chalk.dim(" Dropping into debug agent to diagnose...\n"));
|
|
154
|
+
await runAgentInteractive(root, { registry, initialAgent: "debug" });
|
|
155
|
+
return;
|
|
115
156
|
}
|
|
116
157
|
if (opts.chat !== false) await runAgentInteractive(root, { registry });
|
|
117
158
|
});
|
|
@@ -164,10 +205,28 @@ export function registerCommands(program, registry) {
|
|
|
164
205
|
|
|
165
206
|
program
|
|
166
207
|
.command("config")
|
|
167
|
-
.description("
|
|
208
|
+
.description("Toggle MX_FF_* feature flags and restart affected services")
|
|
168
209
|
.action(async () => {
|
|
169
210
|
const root = requireRoot(program);
|
|
170
|
-
await
|
|
211
|
+
await runFeatureFlags(root);
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
program
|
|
215
|
+
.command("build")
|
|
216
|
+
.description("Build all Foundation service images from source")
|
|
217
|
+
.action(async () => {
|
|
218
|
+
const root = requireRoot(program);
|
|
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");
|
|
171
230
|
});
|
|
172
231
|
|
|
173
232
|
program
|
|
@@ -197,14 +256,14 @@ export function registerCommands(program, registry) {
|
|
|
197
256
|
.action(async () => {
|
|
198
257
|
const skills = await loadSkills(registry);
|
|
199
258
|
if (skills.length === 0) {
|
|
200
|
-
console.log(chalk.
|
|
259
|
+
console.log(chalk.dim(" No skills available."));
|
|
201
260
|
return;
|
|
202
261
|
}
|
|
203
262
|
console.log(chalk.bold.cyan("\n Agent Skills\n"));
|
|
204
263
|
for (const s of skills) {
|
|
205
|
-
const source = s.pluginId ? chalk.
|
|
264
|
+
const source = s.pluginId ? chalk.dim(`(plugin: ${s.pluginId})`) : chalk.dim("(built-in)");
|
|
206
265
|
console.log(` ${chalk.green("●")} ${chalk.bold(s.name)} ${source}`);
|
|
207
|
-
if (s.description) console.log(chalk.
|
|
266
|
+
if (s.description) console.log(chalk.dim(` ${s.description}`));
|
|
208
267
|
}
|
|
209
268
|
console.log("");
|
|
210
269
|
});
|
|
@@ -219,15 +278,15 @@ export function registerCommands(program, registry) {
|
|
|
219
278
|
.description("List installed plugins with status")
|
|
220
279
|
.action(async () => {
|
|
221
280
|
if (registry.plugins.length === 0) {
|
|
222
|
-
console.log(chalk.
|
|
223
|
-
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-*)."));
|
|
224
283
|
return;
|
|
225
284
|
}
|
|
226
285
|
console.log(chalk.bold.cyan("\n Installed Plugins\n"));
|
|
227
286
|
for (const p of registry.plugins) {
|
|
228
|
-
const source = chalk.
|
|
229
|
-
console.log(` ${chalk.green("●")} ${chalk.bold(p.name)} ${chalk.
|
|
230
|
-
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}`));
|
|
231
290
|
}
|
|
232
291
|
console.log("");
|
|
233
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
|
}
|