@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/README.md +62 -40
- package/package.json +4 -3
- package/src/agent/agent.js +26 -34
- package/src/agent/context.js +155 -98
- package/src/agent/llm.js +54 -11
- package/src/auth/coda.js +128 -0
- package/src/auth/index.js +1 -0
- package/src/commands/index.js +23 -9
- package/src/doctor.js +143 -15
- package/src/plugins/api.js +9 -0
- package/src/plugins/index.js +1 -0
- package/src/plugins/knowledge.js +124 -0
- package/src/plugins/registry.js +1 -0
- package/src/setup/setup.js +10 -5
- package/src/setup/wizard.js +15 -11
- package/src/shell.js +2 -2
- package/src/skills/foundation/SKILL.md +200 -66
- package/src/ui/input.js +31 -34
- package/src/ui/spinner.js +36 -10
- 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() {
|
|
@@ -86,22 +86,61 @@ export async function streamAssistantReply(root, messages, systemContent, opts)
|
|
|
86
86
|
const model = opts.model || (anthropicKey ? "claude-sonnet-4-20250514" : "gpt-4o-mini");
|
|
87
87
|
|
|
88
88
|
let fullText = "";
|
|
89
|
-
const
|
|
89
|
+
const display = renderThinking();
|
|
90
90
|
|
|
91
91
|
try {
|
|
92
92
|
if (useClaudeCode) {
|
|
93
|
-
// Build full conversation prompt so context is preserved between turns
|
|
94
93
|
const prompt = buildConversationPrompt(messages);
|
|
95
|
-
fullText = await streamViaClaudeCode(prompt, systemContent)
|
|
94
|
+
fullText = await streamViaClaudeCode(prompt, systemContent, (chunk) => {
|
|
95
|
+
display.setStatus("Responding");
|
|
96
|
+
display.appendContent(chunk);
|
|
97
|
+
});
|
|
96
98
|
} else if (anthropicKey) {
|
|
97
99
|
const { default: Anthropic } = await import("@anthropic-ai/sdk");
|
|
98
100
|
const client = new Anthropic({ apiKey: anthropicKey });
|
|
99
|
-
const
|
|
100
|
-
|
|
101
|
-
|
|
101
|
+
const isClaudeModel = model.includes("claude");
|
|
102
|
+
|
|
103
|
+
const createParams = {
|
|
104
|
+
model, max_tokens: isClaudeModel ? 16000 : 2048,
|
|
105
|
+
system: systemContent, messages, stream: true,
|
|
106
|
+
};
|
|
107
|
+
|
|
108
|
+
// Enable extended thinking for Claude models
|
|
109
|
+
if (isClaudeModel) {
|
|
110
|
+
createParams.thinking = { type: "enabled", budget_tokens: 10000 };
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
let stream;
|
|
114
|
+
try {
|
|
115
|
+
stream = await client.messages.create(createParams);
|
|
116
|
+
} catch (err) {
|
|
117
|
+
// Fall back to regular streaming if extended thinking is unsupported
|
|
118
|
+
if (isClaudeModel && (err?.status === 400 || err?.status === 422)) {
|
|
119
|
+
delete createParams.thinking;
|
|
120
|
+
createParams.max_tokens = 2048;
|
|
121
|
+
stream = await client.messages.create(createParams);
|
|
122
|
+
} else {
|
|
123
|
+
throw err;
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
102
127
|
for await (const event of stream) {
|
|
103
|
-
if (event.type === "
|
|
104
|
-
|
|
128
|
+
if (event.type === "content_block_start") {
|
|
129
|
+
if (event.content_block?.type === "thinking") {
|
|
130
|
+
display.setStatus("Reasoning");
|
|
131
|
+
} else if (event.content_block?.type === "text") {
|
|
132
|
+
display.setThinking(""); // clear thinking preview
|
|
133
|
+
display.setStatus("Responding");
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
if (event.type === "content_block_delta") {
|
|
137
|
+
if (event.delta?.type === "thinking_delta" && event.delta.thinking) {
|
|
138
|
+
display.appendThinking(event.delta.thinking);
|
|
139
|
+
}
|
|
140
|
+
if (event.delta?.type === "text_delta" && event.delta.text) {
|
|
141
|
+
fullText += event.delta.text;
|
|
142
|
+
display.setContent(fullText);
|
|
143
|
+
}
|
|
105
144
|
}
|
|
106
145
|
}
|
|
107
146
|
} else if (openaiKey) {
|
|
@@ -114,13 +153,17 @@ export async function streamAssistantReply(root, messages, systemContent, opts)
|
|
|
114
153
|
});
|
|
115
154
|
for await (const chunk of stream) {
|
|
116
155
|
const delta = chunk.choices?.[0]?.delta?.content;
|
|
117
|
-
if (delta)
|
|
156
|
+
if (delta) {
|
|
157
|
+
fullText += delta;
|
|
158
|
+
display.setStatus("Responding");
|
|
159
|
+
display.setContent(fullText);
|
|
160
|
+
}
|
|
118
161
|
}
|
|
119
162
|
} else {
|
|
120
163
|
throw new Error("No API key (use ANTHROPIC_API_KEY, OPENAI_API_KEY, or ~/.claude auth)");
|
|
121
164
|
}
|
|
122
165
|
} finally {
|
|
123
|
-
|
|
166
|
+
display.stop();
|
|
124
167
|
}
|
|
125
168
|
|
|
126
169
|
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.gray("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.gray(" 1. Go to ") + chalk.cyan(CODA_ACCOUNT_URL));
|
|
81
|
+
console.log(chalk.gray(" 2. Scroll to ") + chalk.white("\"API Settings\""));
|
|
82
|
+
console.log(chalk.gray(" 3. Click ") + chalk.white("\"Generate API token\""));
|
|
83
|
+
console.log(chalk.gray(" 4. Give it a name (e.g. \"Foundation CLI\")"));
|
|
84
|
+
console.log(chalk.gray(" 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.gray("\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.gray(" 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.gray(` Logged in as: ${result.name || result.loginId}`));
|
|
126
|
+
console.log(chalk.gray(" 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/commands/index.js
CHANGED
|
@@ -10,7 +10,7 @@ import { runSetup, runInitWizard } from "../setup/index.js";
|
|
|
10
10
|
import { ensureEcrAuth } from "../setup/aws.js";
|
|
11
11
|
import { runAgentSingleTurn, runAgentInteractive } from "../agent/index.js";
|
|
12
12
|
import { runDoctor } from "../doctor.js";
|
|
13
|
-
import { runLogin } from "../auth/index.js";
|
|
13
|
+
import { runLogin, runCodaLogin } from "../auth/index.js";
|
|
14
14
|
import { runHook, loadSkills } from "../plugins/index.js";
|
|
15
15
|
|
|
16
16
|
export function registerCommands(program, registry) {
|
|
@@ -18,10 +18,19 @@ export function registerCommands(program, registry) {
|
|
|
18
18
|
|
|
19
19
|
program
|
|
20
20
|
.command("login")
|
|
21
|
-
.description("Authenticate with
|
|
21
|
+
.description("Authenticate with services (Claude, Coda)")
|
|
22
|
+
.argument("[service]", "Service to login to: claude (default) or coda")
|
|
22
23
|
.option("--no-browser", "Paste API key in terminal instead of OAuth")
|
|
23
|
-
.action(async (opts) => {
|
|
24
|
-
|
|
24
|
+
.action(async (service, opts) => {
|
|
25
|
+
const target = (service || "claude").toLowerCase();
|
|
26
|
+
if (target === "coda") {
|
|
27
|
+
await runCodaLogin();
|
|
28
|
+
} else if (target === "claude") {
|
|
29
|
+
await runLogin({ browser: opts.browser });
|
|
30
|
+
} else {
|
|
31
|
+
console.error(chalk.red(`Unknown service "${target}". Use: claude, coda`));
|
|
32
|
+
process.exit(1);
|
|
33
|
+
}
|
|
25
34
|
});
|
|
26
35
|
|
|
27
36
|
program
|
|
@@ -82,9 +91,9 @@ export function registerCommands(program, registry) {
|
|
|
82
91
|
.action(async (opts) => {
|
|
83
92
|
const root = requireRoot(program);
|
|
84
93
|
if (opts.message) {
|
|
85
|
-
await runAgentSingleTurn(root, opts.message, { runSuggestions: opts.run !== false, model: opts.model });
|
|
94
|
+
await runAgentSingleTurn(root, opts.message, { runSuggestions: opts.run !== false, model: opts.model, registry });
|
|
86
95
|
} else {
|
|
87
|
-
await runAgentInteractive(root);
|
|
96
|
+
await runAgentInteractive(root, { registry });
|
|
88
97
|
}
|
|
89
98
|
});
|
|
90
99
|
|
|
@@ -97,9 +106,14 @@ export function registerCommands(program, registry) {
|
|
|
97
106
|
const root = requireRoot(program);
|
|
98
107
|
await ensureEcrAuth(root);
|
|
99
108
|
await runHook(registry, "before:up", { root });
|
|
100
|
-
await make(root, "start");
|
|
109
|
+
const result = await make(root, "start");
|
|
101
110
|
await runHook(registry, "after:up", { root });
|
|
102
|
-
if (
|
|
111
|
+
if (result.exitCode !== 0) {
|
|
112
|
+
console.error(chalk.red(`\n Some services failed to start (exit code ${result.exitCode}).`));
|
|
113
|
+
console.error(chalk.gray(" Run `fops doctor` to diagnose or `fops logs` to inspect.\n"));
|
|
114
|
+
process.exit(result.exitCode);
|
|
115
|
+
}
|
|
116
|
+
if (opts.chat !== false) await runAgentInteractive(root, { registry });
|
|
103
117
|
});
|
|
104
118
|
|
|
105
119
|
program
|
|
@@ -107,7 +121,7 @@ export function registerCommands(program, registry) {
|
|
|
107
121
|
.description("Interactive AI assistant (same as foundation agent with no -m)")
|
|
108
122
|
.action(async () => {
|
|
109
123
|
const root = requireRoot(program);
|
|
110
|
-
await runAgentInteractive(root);
|
|
124
|
+
await runAgentInteractive(root, { registry });
|
|
111
125
|
});
|
|
112
126
|
|
|
113
127
|
program
|
package/src/doctor.js
CHANGED
|
@@ -116,6 +116,29 @@ export async function runDoctor(opts = {}, registry = null) {
|
|
|
116
116
|
// ── Prerequisites ──────────────────────────────────
|
|
117
117
|
header("Prerequisites");
|
|
118
118
|
|
|
119
|
+
// winget (Windows only — needed to install other tools)
|
|
120
|
+
let hasWinget = false;
|
|
121
|
+
if (process.platform === "win32") {
|
|
122
|
+
const wingetVer = await cmdVersion("winget");
|
|
123
|
+
if (wingetVer) {
|
|
124
|
+
ok("winget available", wingetVer);
|
|
125
|
+
hasWinget = true;
|
|
126
|
+
} else {
|
|
127
|
+
fail("winget not found", "needed to install Docker", async () => {
|
|
128
|
+
console.log(chalk.cyan(" ▶ Installing winget via Add-AppxPackage…"));
|
|
129
|
+
await execa("powershell", ["-Command", [
|
|
130
|
+
"$url = (Invoke-RestMethod 'https://api.github.com/repos/microsoft/winget-cli/releases/latest').assets",
|
|
131
|
+
"| Where-Object { $_.name -match '.msixbundle$' } | Select-Object -First 1 -ExpandProperty browser_download_url;",
|
|
132
|
+
"$tmp = Join-Path $env:TEMP 'winget.msixbundle';",
|
|
133
|
+
"Invoke-WebRequest -Uri $url -OutFile $tmp;",
|
|
134
|
+
"Add-AppxPackage -Path $tmp;",
|
|
135
|
+
"Remove-Item $tmp",
|
|
136
|
+
].join(" ")], { stdio: "inherit", timeout: 120_000 });
|
|
137
|
+
hasWinget = true;
|
|
138
|
+
});
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
119
142
|
// Docker
|
|
120
143
|
const dockerVer = await cmdVersion("docker");
|
|
121
144
|
if (dockerVer) {
|
|
@@ -124,12 +147,91 @@ export async function runDoctor(opts = {}, registry = null) {
|
|
|
124
147
|
await execa("docker", ["info"], { timeout: 5000 });
|
|
125
148
|
ok("Docker running", dockerVer);
|
|
126
149
|
} catch {
|
|
127
|
-
fail("Docker daemon not running", "start Docker Desktop or dockerd")
|
|
150
|
+
fail("Docker daemon not running", "start Docker Desktop or dockerd", async () => {
|
|
151
|
+
if (process.platform === "darwin") {
|
|
152
|
+
console.log(chalk.cyan(" ▶ open -a Docker"));
|
|
153
|
+
await execa("open", ["-a", "Docker"], { timeout: 10000 });
|
|
154
|
+
} else if (process.platform === "win32") {
|
|
155
|
+
console.log(chalk.cyan(' ▶ start "" "Docker Desktop"'));
|
|
156
|
+
await execa("cmd", ["/c", "start", "", "Docker Desktop"], { timeout: 10000 });
|
|
157
|
+
} else {
|
|
158
|
+
console.log(chalk.cyan(" ▶ sudo systemctl start docker"));
|
|
159
|
+
await execa("sudo", ["systemctl", "start", "docker"], { stdio: "inherit", timeout: 30000 });
|
|
160
|
+
return;
|
|
161
|
+
}
|
|
162
|
+
// macOS / Windows: wait for daemon to become ready
|
|
163
|
+
console.log(chalk.gray(" Waiting for Docker daemon to start…"));
|
|
164
|
+
for (let i = 0; i < 30; i++) {
|
|
165
|
+
await new Promise((r) => setTimeout(r, 2000));
|
|
166
|
+
try {
|
|
167
|
+
await execa("docker", ["info"], { timeout: 5000 });
|
|
168
|
+
return;
|
|
169
|
+
} catch {}
|
|
170
|
+
}
|
|
171
|
+
throw new Error("Docker daemon did not start within 60 s");
|
|
172
|
+
});
|
|
128
173
|
}
|
|
129
174
|
} else {
|
|
130
|
-
fail("Docker not found", "install from docker.com")
|
|
175
|
+
fail("Docker not found", "install from docker.com", async () => {
|
|
176
|
+
if (process.platform === "darwin") {
|
|
177
|
+
let hasBrew = false;
|
|
178
|
+
try { await execa("brew", ["--version"]); hasBrew = true; } catch {}
|
|
179
|
+
if (!hasBrew) {
|
|
180
|
+
console.log(chalk.cyan(" ▶ Installing Homebrew…"));
|
|
181
|
+
await execa("bash", ["-c", 'NONINTERACTIVE=1 /bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)"'], {
|
|
182
|
+
stdio: "inherit", timeout: 300_000,
|
|
183
|
+
});
|
|
184
|
+
// Add brew to PATH for Apple Silicon
|
|
185
|
+
const brewPaths = ["/opt/homebrew/bin/brew", "/usr/local/bin/brew"];
|
|
186
|
+
for (const bp of brewPaths) {
|
|
187
|
+
if (fs.existsSync(bp)) {
|
|
188
|
+
const { stdout } = await execa(bp, ["shellenv"], { timeout: 5000 });
|
|
189
|
+
for (const line of stdout.split("\n")) {
|
|
190
|
+
const m = line.match(/export\s+PATH="([^"]+)"/);
|
|
191
|
+
if (m) process.env.PATH = m[1] + ":" + process.env.PATH;
|
|
192
|
+
}
|
|
193
|
+
break;
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
hasBrew = true;
|
|
197
|
+
}
|
|
198
|
+
console.log(chalk.cyan(" ▶ brew install --cask docker"));
|
|
199
|
+
await execa("brew", ["install", "--cask", "docker"], { stdio: "inherit", timeout: 300_000 });
|
|
200
|
+
console.log(chalk.cyan(" ▶ open -a Docker"));
|
|
201
|
+
await execa("open", ["-a", "Docker"], { timeout: 10000 });
|
|
202
|
+
} else if (process.platform === "win32") {
|
|
203
|
+
if (!hasWinget) throw new Error("winget is required to install Docker — fix winget first");
|
|
204
|
+
console.log(chalk.cyan(" ▶ winget install Docker.DockerDesktop"));
|
|
205
|
+
await execa("winget", ["install", "Docker.DockerDesktop", "--accept-source-agreements", "--accept-package-agreements"], {
|
|
206
|
+
stdio: "inherit", timeout: 300_000,
|
|
207
|
+
});
|
|
208
|
+
console.log(chalk.cyan(' ▶ start "" "Docker Desktop"'));
|
|
209
|
+
await execa("cmd", ["/c", "start", "", "Docker Desktop"], { timeout: 10000 });
|
|
210
|
+
} else {
|
|
211
|
+
console.log(chalk.cyan(" ▶ curl -fsSL https://get.docker.com | sudo sh"));
|
|
212
|
+
await execa("sh", ["-c", "curl -fsSL https://get.docker.com | sudo sh"], {
|
|
213
|
+
stdio: "inherit", timeout: 300_000,
|
|
214
|
+
});
|
|
215
|
+
return;
|
|
216
|
+
}
|
|
217
|
+
// macOS / Windows: wait for daemon after install
|
|
218
|
+
console.log(chalk.gray(" Waiting for Docker daemon to start…"));
|
|
219
|
+
for (let i = 0; i < 30; i++) {
|
|
220
|
+
await new Promise((r) => setTimeout(r, 2000));
|
|
221
|
+
try {
|
|
222
|
+
await execa("docker", ["info"], { timeout: 5000 });
|
|
223
|
+
return;
|
|
224
|
+
} catch {}
|
|
225
|
+
}
|
|
226
|
+
throw new Error("Docker daemon did not start within 60 s");
|
|
227
|
+
});
|
|
131
228
|
}
|
|
132
229
|
|
|
230
|
+
// Docker Compose (bundled with Docker Desktop, but verify)
|
|
231
|
+
const composeVer = await cmdVersion("docker", ["compose", "version"]);
|
|
232
|
+
if (composeVer) ok("Docker Compose", composeVer);
|
|
233
|
+
else fail("Docker Compose not found", "included with Docker Desktop — restart Docker or install the compose plugin");
|
|
234
|
+
|
|
133
235
|
// Git
|
|
134
236
|
const gitVer = await cmdVersion("git");
|
|
135
237
|
if (gitVer) ok("Git available", gitVer);
|
|
@@ -141,6 +243,11 @@ export async function runDoctor(opts = {}, registry = null) {
|
|
|
141
243
|
if (nodeMajor >= 18) ok(`Node.js v${nodeVer}`, ">=18 required");
|
|
142
244
|
else fail(`Node.js v${nodeVer}`, "upgrade to >=18");
|
|
143
245
|
|
|
246
|
+
// Claude CLI (bundled as a dependency)
|
|
247
|
+
const claudeVer = await cmdVersion("claude");
|
|
248
|
+
if (claudeVer) ok("Claude CLI", claudeVer);
|
|
249
|
+
else fail("Claude CLI not found", "run: npm install (included as a dependency)");
|
|
250
|
+
|
|
144
251
|
// AWS CLI (optional)
|
|
145
252
|
const awsVer = await cmdVersion("aws");
|
|
146
253
|
if (awsVer) ok("AWS CLI", awsVer);
|
|
@@ -316,21 +423,42 @@ export async function runDoctor(opts = {}, registry = null) {
|
|
|
316
423
|
|
|
317
424
|
// Host disk available
|
|
318
425
|
try {
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
if (
|
|
325
|
-
const
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
426
|
+
let size, avail, pct;
|
|
427
|
+
if (process.platform === "win32") {
|
|
428
|
+
const { stdout: wmicOut } = await execa("wmic", ["logicaldisk", "where", "DeviceID='C:'", "get", "Size,FreeSpace", "/format:csv"], {
|
|
429
|
+
timeout: 5000, reject: false,
|
|
430
|
+
});
|
|
431
|
+
if (wmicOut) {
|
|
432
|
+
const parts = wmicOut.trim().split("\n").pop()?.split(",");
|
|
433
|
+
if (parts?.length >= 3) {
|
|
434
|
+
const free = parseInt(parts[1], 10);
|
|
435
|
+
const total = parseInt(parts[2], 10);
|
|
436
|
+
if (total > 0) {
|
|
437
|
+
size = `${(total / (1024 ** 3)).toFixed(0)} GB`;
|
|
438
|
+
avail = `${(free / (1024 ** 3)).toFixed(0)} GB`;
|
|
439
|
+
pct = Math.round(((total - free) / total) * 100);
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
} else {
|
|
444
|
+
const { stdout: dfHost } = await execa("df", ["-h", "/"], {
|
|
445
|
+
timeout: 5000, reject: false,
|
|
446
|
+
});
|
|
447
|
+
if (dfHost) {
|
|
448
|
+
const lines = dfHost.trim().split("\n");
|
|
449
|
+
if (lines.length >= 2) {
|
|
450
|
+
const cols = lines[lines.length - 1].split(/\s+/);
|
|
451
|
+
size = cols[1];
|
|
452
|
+
avail = cols[3];
|
|
453
|
+
pct = parseInt(cols[4], 10);
|
|
454
|
+
}
|
|
332
455
|
}
|
|
333
456
|
}
|
|
457
|
+
if (size && avail && pct != null) {
|
|
458
|
+
if (pct >= 90) fail(`Host disk: ${avail} free of ${size}`, "critically low — Docker needs room");
|
|
459
|
+
else if (pct >= 80) warn(`Host disk: ${avail} free of ${size}`, "getting low");
|
|
460
|
+
else ok(`Host disk: ${avail} free of ${size}`);
|
|
461
|
+
}
|
|
334
462
|
} catch {}
|
|
335
463
|
|
|
336
464
|
// Port conflicts
|
package/src/plugins/api.js
CHANGED
|
@@ -33,5 +33,14 @@ export function createPluginApi(pluginId, registry) {
|
|
|
33
33
|
registerHook(event, handler, priority = 0) {
|
|
34
34
|
registry.hooks.push({ pluginId, event, handler, priority });
|
|
35
35
|
},
|
|
36
|
+
|
|
37
|
+
registerKnowledgeSource(source) {
|
|
38
|
+
registry.knowledgeSources.push({
|
|
39
|
+
pluginId,
|
|
40
|
+
name: source.name,
|
|
41
|
+
description: source.description || "",
|
|
42
|
+
search: source.search,
|
|
43
|
+
});
|
|
44
|
+
},
|
|
36
45
|
};
|
|
37
46
|
}
|
package/src/plugins/index.js
CHANGED
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* RAG knowledge orchestrator.
|
|
3
|
+
* Fans out search queries to all registered knowledge sources,
|
|
4
|
+
* merges results, enforces token budgets, and caches.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
const MAX_TOTAL_BYTES = 16_384; // ~4000 tokens
|
|
8
|
+
const MAX_PER_SOURCE_BYTES = 8192;
|
|
9
|
+
const MAX_PER_RESULT_BYTES = 4096;
|
|
10
|
+
const SOURCE_TIMEOUT_MS = 5000;
|
|
11
|
+
const CACHE_TTL_MS = 5 * 60 * 1000; // 5 minutes
|
|
12
|
+
const CACHE_MAX_ENTRIES = 50;
|
|
13
|
+
|
|
14
|
+
/** Simple LRU cache keyed by query string. */
|
|
15
|
+
const cache = new Map();
|
|
16
|
+
|
|
17
|
+
function cacheGet(key) {
|
|
18
|
+
const entry = cache.get(key);
|
|
19
|
+
if (!entry) return null;
|
|
20
|
+
if (Date.now() - entry.ts > CACHE_TTL_MS) {
|
|
21
|
+
cache.delete(key);
|
|
22
|
+
return null;
|
|
23
|
+
}
|
|
24
|
+
// Move to end (most-recently used)
|
|
25
|
+
cache.delete(key);
|
|
26
|
+
cache.set(key, entry);
|
|
27
|
+
return entry.value;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function cacheSet(key, value) {
|
|
31
|
+
// Evict oldest if over capacity
|
|
32
|
+
if (cache.size >= CACHE_MAX_ENTRIES) {
|
|
33
|
+
const oldest = cache.keys().next().value;
|
|
34
|
+
cache.delete(oldest);
|
|
35
|
+
}
|
|
36
|
+
cache.set(key, { value, ts: Date.now() });
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function truncate(str, maxBytes) {
|
|
40
|
+
if (str.length <= maxBytes) return str;
|
|
41
|
+
return str.slice(0, maxBytes - 3) + "...";
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Search all registered knowledge sources and return formatted context.
|
|
46
|
+
* @param {object} registry — plugin registry
|
|
47
|
+
* @param {string} query — user's message / search query
|
|
48
|
+
* @returns {string|null} formatted knowledge block or null if no results
|
|
49
|
+
*/
|
|
50
|
+
export async function searchKnowledge(registry, query) {
|
|
51
|
+
const sources = registry?.knowledgeSources;
|
|
52
|
+
if (!sources?.length || !query?.trim()) return null;
|
|
53
|
+
|
|
54
|
+
const normalizedQuery = query.trim().toLowerCase();
|
|
55
|
+
const cached = cacheGet(normalizedQuery);
|
|
56
|
+
if (cached !== null) return cached;
|
|
57
|
+
|
|
58
|
+
// Fan out to all sources in parallel with individual timeouts
|
|
59
|
+
const sourceResults = await Promise.all(
|
|
60
|
+
sources.map(async (source) => {
|
|
61
|
+
try {
|
|
62
|
+
const results = await Promise.race([
|
|
63
|
+
source.search(query),
|
|
64
|
+
new Promise((_, reject) =>
|
|
65
|
+
setTimeout(() => reject(new Error("timeout")), SOURCE_TIMEOUT_MS),
|
|
66
|
+
),
|
|
67
|
+
]);
|
|
68
|
+
if (!Array.isArray(results)) return [];
|
|
69
|
+
return results.map((r) => ({ ...r, _source: source.name }));
|
|
70
|
+
} catch {
|
|
71
|
+
// Skip failing sources silently
|
|
72
|
+
return [];
|
|
73
|
+
}
|
|
74
|
+
}),
|
|
75
|
+
);
|
|
76
|
+
|
|
77
|
+
// Flatten and sort by score (descending), then by source order
|
|
78
|
+
const allResults = sourceResults.flat();
|
|
79
|
+
if (!allResults.length) {
|
|
80
|
+
cacheSet(normalizedQuery, null);
|
|
81
|
+
return null;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
allResults.sort((a, b) => (b.score ?? 0) - (a.score ?? 0));
|
|
85
|
+
|
|
86
|
+
// Enforce per-source budget
|
|
87
|
+
const sourceBytes = {};
|
|
88
|
+
const budgeted = allResults.filter((r) => {
|
|
89
|
+
const src = r._source;
|
|
90
|
+
const used = sourceBytes[src] || 0;
|
|
91
|
+
const content = truncate(r.content || "", MAX_PER_RESULT_BYTES);
|
|
92
|
+
const size = content.length;
|
|
93
|
+
if (used + size > MAX_PER_SOURCE_BYTES) return false;
|
|
94
|
+
sourceBytes[src] = used + size;
|
|
95
|
+
r._truncatedContent = content;
|
|
96
|
+
return true;
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
// Enforce total budget and format
|
|
100
|
+
let totalBytes = 0;
|
|
101
|
+
const sections = [];
|
|
102
|
+
|
|
103
|
+
for (const r of budgeted) {
|
|
104
|
+
const title = r.title || "Untitled";
|
|
105
|
+
const source = r._source || "unknown";
|
|
106
|
+
const url = r.url ? ` (${r.url})` : "";
|
|
107
|
+
const header = `### [${source}] ${title}${url}`;
|
|
108
|
+
const content = r._truncatedContent || "";
|
|
109
|
+
const section = `${header}\n${content}`;
|
|
110
|
+
|
|
111
|
+
if (totalBytes + section.length > MAX_TOTAL_BYTES) break;
|
|
112
|
+
totalBytes += section.length;
|
|
113
|
+
sections.push(section);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
if (!sections.length) {
|
|
117
|
+
cacheSet(normalizedQuery, null);
|
|
118
|
+
return null;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const result = "## Knowledge Base\n\n" + sections.join("\n\n");
|
|
122
|
+
cacheSet(normalizedQuery, result);
|
|
123
|
+
return result;
|
|
124
|
+
}
|
package/src/plugins/registry.js
CHANGED
|
@@ -10,5 +10,6 @@ export function createRegistry() {
|
|
|
10
10
|
doctorChecks: [], // { pluginId, name, fn }
|
|
11
11
|
hooks: [], // { pluginId, event, handler, priority }
|
|
12
12
|
skills: [], // { pluginId, name, description, content }
|
|
13
|
+
knowledgeSources: [], // { pluginId, name, description, search }
|
|
13
14
|
};
|
|
14
15
|
}
|