agent-tool-forge 0.3.0
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/LICENSE +21 -0
- package/README.md +209 -0
- package/lib/agent-registry.js +170 -0
- package/lib/api-client.js +792 -0
- package/lib/api-loader.js +260 -0
- package/lib/auth.d.ts +25 -0
- package/lib/auth.js +158 -0
- package/lib/checks/check-adapter.js +172 -0
- package/lib/checks/compose.js +42 -0
- package/lib/checks/content-match.js +14 -0
- package/lib/checks/cost-budget.js +11 -0
- package/lib/checks/index.js +18 -0
- package/lib/checks/json-valid.js +15 -0
- package/lib/checks/latency.js +11 -0
- package/lib/checks/length-bounds.js +17 -0
- package/lib/checks/negative-match.js +14 -0
- package/lib/checks/no-hallucinated-numbers.js +63 -0
- package/lib/checks/non-empty.js +34 -0
- package/lib/checks/regex-match.js +12 -0
- package/lib/checks/run-checks.js +84 -0
- package/lib/checks/schema-match.js +26 -0
- package/lib/checks/tool-call-count.js +16 -0
- package/lib/checks/tool-selection.js +34 -0
- package/lib/checks/types.js +45 -0
- package/lib/comparison/compare.js +86 -0
- package/lib/comparison/format.js +104 -0
- package/lib/comparison/index.js +6 -0
- package/lib/comparison/statistics.js +59 -0
- package/lib/comparison/types.js +41 -0
- package/lib/config-schema.js +200 -0
- package/lib/config.d.ts +66 -0
- package/lib/conversation-store.d.ts +77 -0
- package/lib/conversation-store.js +443 -0
- package/lib/db.d.ts +6 -0
- package/lib/db.js +1112 -0
- package/lib/dep-check.js +99 -0
- package/lib/drift-background.js +61 -0
- package/lib/drift-monitor.js +187 -0
- package/lib/eval-runner.js +566 -0
- package/lib/fixtures/fixture-store.js +161 -0
- package/lib/fixtures/index.js +11 -0
- package/lib/forge-engine.js +982 -0
- package/lib/forge-eval-generator.js +417 -0
- package/lib/forge-file-writer.js +386 -0
- package/lib/forge-service-client.js +190 -0
- package/lib/forge-service.d.ts +4 -0
- package/lib/forge-service.js +655 -0
- package/lib/forge-verifier-generator.js +271 -0
- package/lib/handlers/admin.js +151 -0
- package/lib/handlers/agents.js +229 -0
- package/lib/handlers/chat-resume.js +334 -0
- package/lib/handlers/chat-sync.js +320 -0
- package/lib/handlers/chat.js +320 -0
- package/lib/handlers/conversations.js +92 -0
- package/lib/handlers/preferences.js +88 -0
- package/lib/handlers/tools-list.js +58 -0
- package/lib/hitl-engine.d.ts +60 -0
- package/lib/hitl-engine.js +261 -0
- package/lib/http-utils.js +92 -0
- package/lib/index.d.ts +20 -0
- package/lib/index.js +141 -0
- package/lib/init.js +636 -0
- package/lib/manual-entry.js +59 -0
- package/lib/mcp-server.js +252 -0
- package/lib/output-groups.js +54 -0
- package/lib/postgres-store.d.ts +31 -0
- package/lib/postgres-store.js +465 -0
- package/lib/preference-store.d.ts +47 -0
- package/lib/preference-store.js +79 -0
- package/lib/prompt-store.d.ts +42 -0
- package/lib/prompt-store.js +60 -0
- package/lib/rate-limiter.d.ts +30 -0
- package/lib/rate-limiter.js +104 -0
- package/lib/react-engine.d.ts +110 -0
- package/lib/react-engine.js +337 -0
- package/lib/runner/cli.js +156 -0
- package/lib/runner/cost-estimator.js +71 -0
- package/lib/runner/gate.js +46 -0
- package/lib/runner/index.js +165 -0
- package/lib/sidecar.d.ts +83 -0
- package/lib/sidecar.js +161 -0
- package/lib/sse.d.ts +15 -0
- package/lib/sse.js +30 -0
- package/lib/tools-scanner.js +91 -0
- package/lib/tui.js +253 -0
- package/lib/verifier-report.js +78 -0
- package/lib/verifier-runner.js +338 -0
- package/lib/verifier-scanner.js +70 -0
- package/lib/verifier-worker-pool.js +196 -0
- package/lib/views/chat.js +340 -0
- package/lib/views/endpoints.js +203 -0
- package/lib/views/eval-run.js +206 -0
- package/lib/views/forge-agent.js +538 -0
- package/lib/views/forge.js +410 -0
- package/lib/views/main-menu.js +275 -0
- package/lib/views/mediation.js +381 -0
- package/lib/views/model-compare.js +430 -0
- package/lib/views/model-comparison.js +333 -0
- package/lib/views/onboarding.js +470 -0
- package/lib/views/performance.js +237 -0
- package/lib/views/run-evals.js +205 -0
- package/lib/views/settings.js +829 -0
- package/lib/views/tools-evals.js +514 -0
- package/lib/views/verifier-coverage.js +617 -0
- package/lib/workers/verifier-worker.js +52 -0
- package/package.json +123 -0
- package/widget/forge-chat.js +789 -0
package/lib/init.js
ADDED
|
@@ -0,0 +1,636 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Forge Init — Interactive setup wizard.
|
|
3
|
+
*
|
|
4
|
+
* Usage:
|
|
5
|
+
* node lib/index.js init
|
|
6
|
+
* npx forge init
|
|
7
|
+
*
|
|
8
|
+
* Walks through mode, API key, model, database, auth, API discovery,
|
|
9
|
+
* first agent, and widget — then generates forge.config.json, .env,
|
|
10
|
+
* and optionally forge-widget.html.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { existsSync, readFileSync, writeFileSync, renameSync } from 'fs';
|
|
14
|
+
import { resolve, dirname } from 'path';
|
|
15
|
+
import { fileURLToPath } from 'url';
|
|
16
|
+
import * as readline from 'readline';
|
|
17
|
+
import * as crypto from 'crypto';
|
|
18
|
+
import { mergeDefaults, validateConfig } from './config-schema.js';
|
|
19
|
+
import { ensureDependencyInteractive } from './dep-check.js';
|
|
20
|
+
|
|
21
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
22
|
+
|
|
23
|
+
// ── Security / file helpers ──────────────────────────────────────────────────
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Reject values containing newline or null characters before writing to .env.
|
|
27
|
+
* @param {*} val
|
|
28
|
+
* @returns {string}
|
|
29
|
+
*/
|
|
30
|
+
export function sanitizeEnvValue(val) {
|
|
31
|
+
if (typeof val !== 'string') return String(val ?? '');
|
|
32
|
+
if (/[\r\n\0]/.test(val)) {
|
|
33
|
+
throw new Error(`Invalid value: newline characters are not allowed in .env values`);
|
|
34
|
+
}
|
|
35
|
+
return val;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Validate a URL is safe to fetch (no private/loopback/file addresses).
|
|
40
|
+
* Throws if the URL is unsafe.
|
|
41
|
+
* @param {string} rawUrl
|
|
42
|
+
*/
|
|
43
|
+
export function assertSafeUrl(rawUrl) {
|
|
44
|
+
let u;
|
|
45
|
+
try { u = new URL(rawUrl); } catch { throw new Error('Invalid URL format'); }
|
|
46
|
+
if (!['http:', 'https:'].includes(u.protocol)) {
|
|
47
|
+
throw new Error('Only http:// and https:// URLs are allowed');
|
|
48
|
+
}
|
|
49
|
+
const host = u.hostname.toLowerCase();
|
|
50
|
+
// URL.hostname wraps IPv6 addresses in brackets (e.g. [fc00::1]), so strip
|
|
51
|
+
// them before testing against IPv6 prefix patterns.
|
|
52
|
+
const bare = host.startsWith('[') ? host.slice(1, -1) : host;
|
|
53
|
+
// Block private/loopback/link-local addresses
|
|
54
|
+
const isPrivateIPv4 = (
|
|
55
|
+
host === 'localhost' ||
|
|
56
|
+
/^127\./.test(host) ||
|
|
57
|
+
/^10\./.test(host) ||
|
|
58
|
+
/^172\.(1[6-9]|2\d|3[01])\./.test(host) ||
|
|
59
|
+
/^192\.168\./.test(host) ||
|
|
60
|
+
/^169\.254\./.test(host)
|
|
61
|
+
);
|
|
62
|
+
// ULA range fc00::/7 covers any address starting with fc or fd.
|
|
63
|
+
// Use bare prefix match (no digit count) so fc::1 and fd::1 are also blocked.
|
|
64
|
+
const isPrivateIPv6 = (
|
|
65
|
+
bare === '::1' ||
|
|
66
|
+
/^fe80:/i.test(bare) ||
|
|
67
|
+
/^fc/i.test(bare) ||
|
|
68
|
+
/^fd/i.test(bare)
|
|
69
|
+
);
|
|
70
|
+
if (isPrivateIPv4 || isPrivateIPv6) {
|
|
71
|
+
throw new Error('Private, loopback, and link-local URLs are not allowed');
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Write a file atomically via a temp file + rename.
|
|
77
|
+
* @param {string} destPath
|
|
78
|
+
* @param {string} content
|
|
79
|
+
*/
|
|
80
|
+
export function atomicWriteFile(destPath, content) {
|
|
81
|
+
const tmp = `${destPath}.tmp.${process.pid}`;
|
|
82
|
+
writeFileSync(tmp, content, 'utf8');
|
|
83
|
+
renameSync(tmp, destPath);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// ── Prompt helpers ──────────────────────────────────────────────────────────
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Prompt for a single line.
|
|
90
|
+
* @param {readline.Interface} rl
|
|
91
|
+
* @param {string} question
|
|
92
|
+
* @param {string} [defaultValue]
|
|
93
|
+
* @returns {Promise<string>}
|
|
94
|
+
*/
|
|
95
|
+
export function ask(rl, question, defaultValue = '') {
|
|
96
|
+
const suffix = defaultValue ? ` [${defaultValue}]` : '';
|
|
97
|
+
return new Promise((resolve) => {
|
|
98
|
+
rl.question(`${question}${suffix}: `, (ans) => {
|
|
99
|
+
resolve(ans.trim() || defaultValue);
|
|
100
|
+
});
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Numbered choice list. Returns the value from the selected option.
|
|
106
|
+
* @param {readline.Interface} rl
|
|
107
|
+
* @param {string} question
|
|
108
|
+
* @param {{ label: string, value: * }[]} options
|
|
109
|
+
* @param {number} [defaultIdx=0] - 0-based default index
|
|
110
|
+
* @returns {Promise<*>}
|
|
111
|
+
*/
|
|
112
|
+
export function choose(rl, question, options, defaultIdx = 0) {
|
|
113
|
+
console.log(`\n${question}`);
|
|
114
|
+
for (let i = 0; i < options.length; i++) {
|
|
115
|
+
console.log(` ${i + 1}. ${options[i].label}`);
|
|
116
|
+
}
|
|
117
|
+
return new Promise((resolve) => {
|
|
118
|
+
rl.question(`Choose [1-${options.length}] (default: ${defaultIdx + 1}): `, (ans) => {
|
|
119
|
+
const num = parseInt(ans.trim(), 10);
|
|
120
|
+
if (num >= 1 && num <= options.length) {
|
|
121
|
+
resolve(options[num - 1].value);
|
|
122
|
+
} else {
|
|
123
|
+
resolve(options[defaultIdx].value);
|
|
124
|
+
}
|
|
125
|
+
});
|
|
126
|
+
});
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Yes/no confirmation.
|
|
131
|
+
* @param {readline.Interface} rl
|
|
132
|
+
* @param {string} question
|
|
133
|
+
* @param {boolean} [defaultYes=true]
|
|
134
|
+
* @returns {Promise<boolean>}
|
|
135
|
+
*/
|
|
136
|
+
export function confirm(rl, question, defaultYes = true) {
|
|
137
|
+
const hint = defaultYes ? 'Y/n' : 'y/N';
|
|
138
|
+
return new Promise((resolve) => {
|
|
139
|
+
rl.question(`${question} (${hint}): `, (ans) => {
|
|
140
|
+
const a = ans.trim().toLowerCase();
|
|
141
|
+
if (a === '') resolve(defaultYes);
|
|
142
|
+
else resolve(a === 'y' || a === 'yes');
|
|
143
|
+
});
|
|
144
|
+
});
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// ── Provider detection ──────────────────────────────────────────────────────
|
|
148
|
+
|
|
149
|
+
const PROVIDER_PREFIXES = [
|
|
150
|
+
{ prefix: 'sk-ant-', provider: 'anthropic', envKey: 'ANTHROPIC_API_KEY' },
|
|
151
|
+
{ prefix: 'sk-', provider: 'openai', envKey: 'OPENAI_API_KEY' },
|
|
152
|
+
{ prefix: 'AIza', provider: 'google', envKey: 'GOOGLE_API_KEY' },
|
|
153
|
+
];
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* Detect provider from API key value prefix.
|
|
157
|
+
* @param {string} keyValue
|
|
158
|
+
* @returns {{ provider: string, envKey: string }}
|
|
159
|
+
*/
|
|
160
|
+
export function detectProvider(keyValue) {
|
|
161
|
+
for (const { prefix, provider, envKey } of PROVIDER_PREFIXES) {
|
|
162
|
+
if (keyValue.startsWith(prefix)) return { provider, envKey };
|
|
163
|
+
}
|
|
164
|
+
return { provider: 'anthropic', envKey: 'ANTHROPIC_API_KEY' };
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// ── Model lists ─────────────────────────────────────────────────────────────
|
|
168
|
+
|
|
169
|
+
const MODEL_LISTS = {
|
|
170
|
+
anthropic: [
|
|
171
|
+
{ label: 'claude-sonnet-4-6 (recommended)', value: 'claude-sonnet-4-6' },
|
|
172
|
+
{ label: 'claude-opus-4-6', value: 'claude-opus-4-6' },
|
|
173
|
+
{ label: 'claude-haiku-4-5-20251001', value: 'claude-haiku-4-5-20251001' },
|
|
174
|
+
],
|
|
175
|
+
openai: [
|
|
176
|
+
{ label: 'gpt-4o (recommended)', value: 'gpt-4o' },
|
|
177
|
+
{ label: 'gpt-4o-mini', value: 'gpt-4o-mini' },
|
|
178
|
+
{ label: 'o3-mini', value: 'o3-mini' },
|
|
179
|
+
],
|
|
180
|
+
google: [
|
|
181
|
+
{ label: 'gemini-2.0-flash (recommended)', value: 'gemini-2.0-flash' },
|
|
182
|
+
{ label: 'gemini-2.5-pro-exp', value: 'gemini-2.5-pro-exp' },
|
|
183
|
+
],
|
|
184
|
+
};
|
|
185
|
+
|
|
186
|
+
// ── Admin key ───────────────────────────────────────────────────────────────
|
|
187
|
+
|
|
188
|
+
/** Generate a 64-char hex admin key. */
|
|
189
|
+
export function generateAdminKey() {
|
|
190
|
+
return crypto.randomBytes(32).toString('hex');
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// ── .env helpers ────────────────────────────────────────────────────────────
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* Load .env file into a Map-like object. Preserves insertion order.
|
|
197
|
+
* @param {string} envPath
|
|
198
|
+
* @returns {Record<string, string>}
|
|
199
|
+
*/
|
|
200
|
+
export function loadEnv(envPath) {
|
|
201
|
+
if (!existsSync(envPath)) return {};
|
|
202
|
+
const lines = readFileSync(envPath, 'utf-8').split('\n');
|
|
203
|
+
const out = {};
|
|
204
|
+
for (const line of lines) {
|
|
205
|
+
const trimmed = line.trim();
|
|
206
|
+
if (!trimmed || trimmed.startsWith('#')) continue;
|
|
207
|
+
const eqIdx = trimmed.indexOf('=');
|
|
208
|
+
if (eqIdx === -1) continue;
|
|
209
|
+
const key = trimmed.slice(0, eqIdx).trim();
|
|
210
|
+
const val = trimmed.slice(eqIdx + 1).trim().replace(/^["']|["']$/g, '');
|
|
211
|
+
out[key] = val;
|
|
212
|
+
}
|
|
213
|
+
return out;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* Merge new entries into an existing .env file. Existing keys are preserved.
|
|
218
|
+
* Returns { added: string[], skipped: string[] }.
|
|
219
|
+
* @param {string} envPath
|
|
220
|
+
* @param {Record<string, string>} newEntries
|
|
221
|
+
* @returns {{ added: string[], skipped: string[] }}
|
|
222
|
+
*/
|
|
223
|
+
export function mergeEnvFile(envPath, newEntries) {
|
|
224
|
+
const existing = loadEnv(envPath);
|
|
225
|
+
const added = [];
|
|
226
|
+
const skipped = [];
|
|
227
|
+
|
|
228
|
+
// Track which keys already existed before we add new ones
|
|
229
|
+
const preExistingKeys = new Set(Object.keys(existing));
|
|
230
|
+
|
|
231
|
+
for (const [key, value] of Object.entries(newEntries)) {
|
|
232
|
+
if (key in existing) {
|
|
233
|
+
skipped.push(key);
|
|
234
|
+
} else {
|
|
235
|
+
existing[key] = value;
|
|
236
|
+
added.push(key);
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
const lines = Object.entries(existing).map(([k, v]) => {
|
|
241
|
+
// Apply sanitization to newly added values
|
|
242
|
+
if (!preExistingKeys.has(k)) {
|
|
243
|
+
return `${k}=${sanitizeEnvValue(v)}`;
|
|
244
|
+
}
|
|
245
|
+
return `${k}=${v}`;
|
|
246
|
+
});
|
|
247
|
+
atomicWriteFile(envPath, lines.join('\n') + '\n');
|
|
248
|
+
|
|
249
|
+
return { added, skipped };
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
// ── Widget HTML ─────────────────────────────────────────────────────────────
|
|
253
|
+
|
|
254
|
+
/**
|
|
255
|
+
* Generate a standalone widget HTML file.
|
|
256
|
+
* @param {string} filePath
|
|
257
|
+
* @param {number} port
|
|
258
|
+
* @param {string|null} agentId
|
|
259
|
+
*/
|
|
260
|
+
export function writeWidgetHtml(filePath, port, agentId) {
|
|
261
|
+
const agentAttr = agentId ? ` agent="${agentId}"` : '';
|
|
262
|
+
const html = `<!DOCTYPE html>
|
|
263
|
+
<html lang="en">
|
|
264
|
+
<head>
|
|
265
|
+
<meta charset="UTF-8">
|
|
266
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
267
|
+
<title>Forge Chat Widget</title>
|
|
268
|
+
<style>
|
|
269
|
+
body { margin: 0; font-family: system-ui, sans-serif; }
|
|
270
|
+
</style>
|
|
271
|
+
</head>
|
|
272
|
+
<body>
|
|
273
|
+
|
|
274
|
+
<!-- Copy this script tag into your app's HTML -->
|
|
275
|
+
<script src="http://localhost:${port}/widget/forge-chat.js"></script>
|
|
276
|
+
|
|
277
|
+
<!-- Copy this tag where you want the chat widget to appear -->
|
|
278
|
+
<forge-chat endpoint="http://localhost:${port}"${agentAttr}></forge-chat>
|
|
279
|
+
|
|
280
|
+
<!--
|
|
281
|
+
Notes:
|
|
282
|
+
- The script + custom element are all you need.
|
|
283
|
+
- Change "endpoint" to your production sidecar URL when deploying.
|
|
284
|
+
${agentId ? `- agent="${agentId}" routes chat to the "${agentId}" agent.` : '- Add agent="your-agent-id" to route chat to a specific agent.'}
|
|
285
|
+
- The widget uses Shadow DOM — no style conflicts with your app.
|
|
286
|
+
-->
|
|
287
|
+
|
|
288
|
+
</body>
|
|
289
|
+
</html>
|
|
290
|
+
`;
|
|
291
|
+
atomicWriteFile(filePath, html);
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
// ── Main wizard ─────────────────────────────────────────────────────────────
|
|
295
|
+
|
|
296
|
+
const AGENT_ID_RE = /^[a-z0-9_-]+$/;
|
|
297
|
+
|
|
298
|
+
/**
|
|
299
|
+
* Run the interactive init wizard.
|
|
300
|
+
* @param {{ projectRoot?: string, rl?: readline.Interface }} [opts]
|
|
301
|
+
*/
|
|
302
|
+
export async function runInit(opts = {}) {
|
|
303
|
+
const projectRoot = opts.projectRoot || resolve(__dirname, '..');
|
|
304
|
+
const ownRl = !opts.rl;
|
|
305
|
+
const rl = opts.rl || readline.createInterface({ input: process.stdin, output: process.stdout });
|
|
306
|
+
|
|
307
|
+
const configPath = resolve(projectRoot, 'forge.config.json');
|
|
308
|
+
const envPath = resolve(projectRoot, '.env');
|
|
309
|
+
const widgetPath = resolve(projectRoot, 'forge-widget.html');
|
|
310
|
+
|
|
311
|
+
const filesWritten = [];
|
|
312
|
+
const envKeysAdded = [];
|
|
313
|
+
const envKeysSkipped = [];
|
|
314
|
+
|
|
315
|
+
try {
|
|
316
|
+
// ── Step 1: Mode ──────────────────────────────────────────────────────
|
|
317
|
+
const mode = await choose(rl, 'How will you use Forge?', [
|
|
318
|
+
{ label: 'Sidecar only — embed AI agent runtime in your app', value: 'sidecar' },
|
|
319
|
+
{ label: 'TUI + Sidecar — full dev workflow + production runtime', value: 'both' },
|
|
320
|
+
{ label: 'TUI only — tool development and testing', value: 'tui' },
|
|
321
|
+
], 1); // default: both
|
|
322
|
+
|
|
323
|
+
const hasSidecar = mode === 'sidecar' || mode === 'both';
|
|
324
|
+
|
|
325
|
+
// ── Step 2: API Key ───────────────────────────────────────────────────
|
|
326
|
+
const existingEnv = loadEnv(envPath);
|
|
327
|
+
const envKeys = Object.keys(existingEnv);
|
|
328
|
+
const hasKey = envKeys.some(k => /ANTHROPIC|OPENAI|GOOGLE|GEMINI/i.test(k))
|
|
329
|
+
|| process.env.ANTHROPIC_API_KEY
|
|
330
|
+
|| process.env.OPENAI_API_KEY;
|
|
331
|
+
|
|
332
|
+
let provider = 'anthropic';
|
|
333
|
+
let apiKeyEnvName = null;
|
|
334
|
+
let apiKeyValue = null;
|
|
335
|
+
|
|
336
|
+
if (hasKey) {
|
|
337
|
+
// Detect provider from existing key
|
|
338
|
+
if (envKeys.some(k => /ANTHROPIC/i.test(k)) || process.env.ANTHROPIC_API_KEY) provider = 'anthropic';
|
|
339
|
+
else if (envKeys.some(k => /OPENAI/i.test(k)) || process.env.OPENAI_API_KEY) provider = 'openai';
|
|
340
|
+
else if (envKeys.some(k => /GOOGLE|GEMINI/i.test(k))) provider = 'google';
|
|
341
|
+
console.log(`\nAPI key detected (${provider}). Skipping.`);
|
|
342
|
+
} else {
|
|
343
|
+
// eslint-disable-next-line no-constant-condition
|
|
344
|
+
while (true) {
|
|
345
|
+
console.log('');
|
|
346
|
+
const keyInput = await ask(rl, 'Enter your API key (or KEY_NAME=value)');
|
|
347
|
+
if (!keyInput) break;
|
|
348
|
+
if (keyInput.includes('=')) {
|
|
349
|
+
const eqIdx = keyInput.indexOf('=');
|
|
350
|
+
const candidateName = keyInput.slice(0, eqIdx).trim().toUpperCase();
|
|
351
|
+
if (!/^[A-Z_][A-Z0-9_]*$/.test(candidateName)) {
|
|
352
|
+
console.log(' ✗ Invalid env var name. Use only letters, digits, and underscores (e.g. MY_API_KEY=sk-...)');
|
|
353
|
+
continue;
|
|
354
|
+
}
|
|
355
|
+
apiKeyEnvName = candidateName;
|
|
356
|
+
apiKeyValue = keyInput.slice(eqIdx + 1).trim();
|
|
357
|
+
// Infer provider from env name
|
|
358
|
+
if (/ANTHROPIC/i.test(apiKeyEnvName)) provider = 'anthropic';
|
|
359
|
+
else if (/OPENAI/i.test(apiKeyEnvName)) provider = 'openai';
|
|
360
|
+
else if (/GOOGLE|GEMINI/i.test(apiKeyEnvName)) provider = 'google';
|
|
361
|
+
else {
|
|
362
|
+
const detected = detectProvider(apiKeyValue);
|
|
363
|
+
provider = detected.provider;
|
|
364
|
+
}
|
|
365
|
+
} else {
|
|
366
|
+
const detected = detectProvider(keyInput);
|
|
367
|
+
provider = detected.provider;
|
|
368
|
+
apiKeyEnvName = detected.envKey;
|
|
369
|
+
apiKeyValue = keyInput;
|
|
370
|
+
}
|
|
371
|
+
break;
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
// ── Step 3: Model ─────────────────────────────────────────────────────
|
|
376
|
+
const modelOptions = MODEL_LISTS[provider] || MODEL_LISTS.anthropic;
|
|
377
|
+
const model = await choose(rl, 'Choose your default model:', modelOptions, 0);
|
|
378
|
+
|
|
379
|
+
// ── Step 4: Storage (sidecar modes) ────────────────────────────────────
|
|
380
|
+
let dbType = 'sqlite';
|
|
381
|
+
let dbUrl = null;
|
|
382
|
+
let storeDbUrlInEnv = false;
|
|
383
|
+
let conversationStore = 'sqlite';
|
|
384
|
+
let redisUrl = null;
|
|
385
|
+
let storeRedisUrlInEnv = false;
|
|
386
|
+
|
|
387
|
+
if (hasSidecar) {
|
|
388
|
+
const storageChoice = await choose(rl, 'Where should chat history be stored?', [
|
|
389
|
+
{ label: 'SQLite (local file, zero setup — default)', value: 'sqlite' },
|
|
390
|
+
{ label: 'Postgres (shared DB for dev+prod, remote access)', value: 'postgres' },
|
|
391
|
+
{ label: 'Redis (in-memory, fast, auto-expiry)', value: 'redis' },
|
|
392
|
+
], 0);
|
|
393
|
+
|
|
394
|
+
if (storageChoice === 'postgres') {
|
|
395
|
+
dbType = 'postgres';
|
|
396
|
+
conversationStore = 'postgres';
|
|
397
|
+
dbUrl = await ask(rl, 'Postgres connection URL (e.g. postgresql://user:pass@host:5432/forge)');
|
|
398
|
+
storeDbUrlInEnv = await confirm(rl, 'Store connection URL in .env as DATABASE_URL?', true);
|
|
399
|
+
await ensureDependencyInteractive('pg', rl);
|
|
400
|
+
} else if (storageChoice === 'redis') {
|
|
401
|
+
conversationStore = 'redis';
|
|
402
|
+
redisUrl = await ask(rl, 'Redis URL', 'redis://localhost:6379');
|
|
403
|
+
storeRedisUrlInEnv = await confirm(rl, 'Store Redis URL in .env as REDIS_URL?', true);
|
|
404
|
+
await ensureDependencyInteractive('redis', rl);
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
// ── Step 5: Auth Mode (sidecar only) ──────────────────────────────────
|
|
409
|
+
let authMode = 'trust';
|
|
410
|
+
let signingKeyEnvName = null;
|
|
411
|
+
let signingKeyValue = null;
|
|
412
|
+
|
|
413
|
+
if (hasSidecar) {
|
|
414
|
+
authMode = await choose(rl, 'How will your app authenticate users?', [
|
|
415
|
+
{ label: 'Trust mode (no verification — local dev)', value: 'trust' },
|
|
416
|
+
{ label: 'JWT verify (HMAC-SHA256 — production)', value: 'verify' },
|
|
417
|
+
], 0);
|
|
418
|
+
|
|
419
|
+
if (authMode === 'verify') {
|
|
420
|
+
signingKeyEnvName = await ask(rl, 'Signing key env var name', 'JWT_SIGNING_KEY');
|
|
421
|
+
signingKeyValue = await ask(rl, `Value for ${signingKeyEnvName} (leave empty to use trust mode instead)`);
|
|
422
|
+
if (!signingKeyValue.trim()) {
|
|
423
|
+
// User left empty — fall back to trust mode, don't write placeholder
|
|
424
|
+
authMode = 'trust';
|
|
425
|
+
signingKeyEnvName = null;
|
|
426
|
+
signingKeyValue = null;
|
|
427
|
+
console.log(' → Using trust mode (no JWT verification). Set auth.mode to "verify" later by adding a signing key.');
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
// ── Step 6: API Discovery (sidecar only) ─────────────────────────────
|
|
433
|
+
let discoveryUrl = null;
|
|
434
|
+
|
|
435
|
+
if (hasSidecar) {
|
|
436
|
+
console.log('');
|
|
437
|
+
discoveryUrl = await ask(rl, 'OpenAPI spec URL? (press Enter to skip)');
|
|
438
|
+
if (discoveryUrl) {
|
|
439
|
+
// Validate URL is safe before fetching
|
|
440
|
+
try {
|
|
441
|
+
assertSafeUrl(discoveryUrl);
|
|
442
|
+
} catch (err) {
|
|
443
|
+
console.log(` ✗ Discovery URL rejected: ${err.message}`);
|
|
444
|
+
discoveryUrl = null;
|
|
445
|
+
}
|
|
446
|
+
if (discoveryUrl) {
|
|
447
|
+
// Attempt fetch with 10s timeout and 512KB response cap
|
|
448
|
+
try {
|
|
449
|
+
const resp = await fetch(discoveryUrl, { signal: AbortSignal.timeout(10000) });
|
|
450
|
+
const text = await resp.text();
|
|
451
|
+
if (text.length > 512 * 1024) throw new Error('Response too large (max 512KB)');
|
|
452
|
+
if (resp.ok) {
|
|
453
|
+
const body = JSON.parse(text);
|
|
454
|
+
const paths = Object.keys(body.paths || {});
|
|
455
|
+
console.log(` Found ${paths.length} path(s) in spec.`);
|
|
456
|
+
} else {
|
|
457
|
+
console.log(` Warning: got HTTP ${resp.status} — saving URL anyway.`);
|
|
458
|
+
}
|
|
459
|
+
} catch (err) {
|
|
460
|
+
console.log(` Could not fetch spec (${err.message}) — saving URL anyway.`);
|
|
461
|
+
}
|
|
462
|
+
}
|
|
463
|
+
} else {
|
|
464
|
+
console.log(' You can add API discovery later in forge.config.json → api.discovery');
|
|
465
|
+
discoveryUrl = null;
|
|
466
|
+
}
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
// ── Step 7: First Agent (sidecar only) ───────────────────────────────
|
|
470
|
+
let agent = null;
|
|
471
|
+
|
|
472
|
+
if (hasSidecar) {
|
|
473
|
+
const wantAgent = await confirm(rl, '\nCreate your first agent?', true);
|
|
474
|
+
if (wantAgent) {
|
|
475
|
+
let agentId = '';
|
|
476
|
+
while (!AGENT_ID_RE.test(agentId)) {
|
|
477
|
+
agentId = await ask(rl, 'Agent slug (lowercase, hyphens, underscores)', 'support');
|
|
478
|
+
if (!AGENT_ID_RE.test(agentId)) {
|
|
479
|
+
console.log(' Must match /^[a-z0-9_-]+$/. Try again.');
|
|
480
|
+
}
|
|
481
|
+
}
|
|
482
|
+
const displayName = await ask(rl, 'Display name', agentId.replace(/[-_]/g, ' ').replace(/\b\w/g, c => c.toUpperCase()));
|
|
483
|
+
agent = { id: agentId, displayName, toolAllowlist: '*', isDefault: true };
|
|
484
|
+
}
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
// ── Step 8: Widget Snippet (sidecar only) ────────────────────────────
|
|
488
|
+
let writeWidget = false;
|
|
489
|
+
|
|
490
|
+
if (hasSidecar) {
|
|
491
|
+
writeWidget = await confirm(rl, 'Generate a chat widget HTML snippet?', true);
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
// ── Assemble config ─────────────────────────────────────────────────
|
|
495
|
+
|
|
496
|
+
const raw = { defaultModel: model };
|
|
497
|
+
|
|
498
|
+
// Generate adminKey value for .env (never written plaintext to config)
|
|
499
|
+
const adminKeyValue = hasSidecar ? generateAdminKey() : null;
|
|
500
|
+
|
|
501
|
+
if (hasSidecar) {
|
|
502
|
+
raw.sidecar = { enabled: true, port: 8001 };
|
|
503
|
+
raw.adminKey = '${FORGE_ADMIN_KEY}';
|
|
504
|
+
raw.auth = { mode: authMode };
|
|
505
|
+
if (authMode === 'verify') {
|
|
506
|
+
raw.auth.signingKey = `\${${signingKeyEnvName}}`;
|
|
507
|
+
}
|
|
508
|
+
raw.database = { type: dbType };
|
|
509
|
+
if (dbType === 'postgres') {
|
|
510
|
+
raw.database.url = storeDbUrlInEnv ? '${DATABASE_URL}' : dbUrl;
|
|
511
|
+
}
|
|
512
|
+
raw.conversation = { store: conversationStore };
|
|
513
|
+
if (conversationStore === 'redis') {
|
|
514
|
+
raw.conversation.redis = { url: storeRedisUrlInEnv ? '${REDIS_URL}' : redisUrl };
|
|
515
|
+
}
|
|
516
|
+
if (discoveryUrl) {
|
|
517
|
+
raw.api = { discovery: discoveryUrl };
|
|
518
|
+
}
|
|
519
|
+
if (agent) {
|
|
520
|
+
raw.agents = [agent];
|
|
521
|
+
}
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
// Validate
|
|
525
|
+
const { valid, errors } = validateConfig(raw);
|
|
526
|
+
if (!valid) {
|
|
527
|
+
console.log('\nConfig validation warnings:');
|
|
528
|
+
for (const e of errors) console.log(` - ${e}`);
|
|
529
|
+
const proceed = await confirm(rl, 'Proceed anyway?', true);
|
|
530
|
+
if (!proceed) {
|
|
531
|
+
console.log('Aborted.');
|
|
532
|
+
return;
|
|
533
|
+
}
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
const merged = mergeDefaults(raw);
|
|
537
|
+
|
|
538
|
+
// ── Write forge.config.json ──────────────────────────────────────────
|
|
539
|
+
|
|
540
|
+
if (existsSync(configPath)) {
|
|
541
|
+
const overwrite = await confirm(rl, '\nforge.config.json already exists. Overwrite?', false);
|
|
542
|
+
if (!overwrite) {
|
|
543
|
+
console.log(' Skipping forge.config.json');
|
|
544
|
+
} else {
|
|
545
|
+
atomicWriteFile(configPath, JSON.stringify(merged, null, 2) + '\n');
|
|
546
|
+
filesWritten.push('forge.config.json');
|
|
547
|
+
}
|
|
548
|
+
} else {
|
|
549
|
+
atomicWriteFile(configPath, JSON.stringify(merged, null, 2) + '\n');
|
|
550
|
+
filesWritten.push('forge.config.json');
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
// ── Write .env ───────────────────────────────────────────────────────
|
|
554
|
+
|
|
555
|
+
const envEntries = {};
|
|
556
|
+
if (apiKeyEnvName && apiKeyValue) {
|
|
557
|
+
envEntries[apiKeyEnvName] = apiKeyValue;
|
|
558
|
+
}
|
|
559
|
+
if (hasSidecar && adminKeyValue) {
|
|
560
|
+
envEntries.FORGE_ADMIN_KEY = adminKeyValue;
|
|
561
|
+
}
|
|
562
|
+
if (signingKeyEnvName && signingKeyValue) {
|
|
563
|
+
envEntries[signingKeyEnvName] = signingKeyValue;
|
|
564
|
+
}
|
|
565
|
+
if (storeDbUrlInEnv && dbUrl) {
|
|
566
|
+
envEntries.DATABASE_URL = dbUrl;
|
|
567
|
+
}
|
|
568
|
+
if (storeRedisUrlInEnv && redisUrl) {
|
|
569
|
+
envEntries.REDIS_URL = redisUrl;
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
if (Object.keys(envEntries).length > 0) {
|
|
573
|
+
const { added, skipped } = mergeEnvFile(envPath, envEntries);
|
|
574
|
+
envKeysAdded.push(...added);
|
|
575
|
+
envKeysSkipped.push(...skipped);
|
|
576
|
+
filesWritten.push('.env');
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
// ── Write widget HTML ────────────────────────────────────────────────
|
|
580
|
+
|
|
581
|
+
if (writeWidget) {
|
|
582
|
+
if (existsSync(widgetPath)) {
|
|
583
|
+
const overwrite = await confirm(rl, 'forge-widget.html already exists. Overwrite?', false);
|
|
584
|
+
if (!overwrite) {
|
|
585
|
+
console.log(' Skipping forge-widget.html');
|
|
586
|
+
} else {
|
|
587
|
+
writeWidgetHtml(widgetPath, merged.sidecar.port, agent?.id || null);
|
|
588
|
+
filesWritten.push('forge-widget.html');
|
|
589
|
+
}
|
|
590
|
+
} else {
|
|
591
|
+
writeWidgetHtml(widgetPath, merged.sidecar.port, agent?.id || null);
|
|
592
|
+
filesWritten.push('forge-widget.html');
|
|
593
|
+
}
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
// ── Summary ──────────────────────────────────────────────────────────
|
|
597
|
+
|
|
598
|
+
const modeLabel = mode === 'sidecar' ? 'sidecar' : mode === 'both' ? 'tui + sidecar' : 'tui';
|
|
599
|
+
const dbLabel = conversationStore === 'redis' ? 'redis' : dbType === 'postgres' ? 'postgres' : 'sqlite';
|
|
600
|
+
const portLabel = hasSidecar ? `, port ${merged.sidecar.port}` : '';
|
|
601
|
+
|
|
602
|
+
console.log('\n── Forge initialized ──────────────────────────────────────');
|
|
603
|
+
for (const f of filesWritten) {
|
|
604
|
+
if (f === 'forge.config.json') {
|
|
605
|
+
console.log(` ✓ forge.config.json (${modeLabel}${hasSidecar ? ' + ' + dbLabel : ''}${portLabel})`);
|
|
606
|
+
} else if (f === '.env') {
|
|
607
|
+
const keyList = [...envKeysAdded];
|
|
608
|
+
if (envKeysSkipped.length > 0) {
|
|
609
|
+
keyList.push(`skipped: ${envKeysSkipped.join(', ')}`);
|
|
610
|
+
}
|
|
611
|
+
console.log(` ✓ .env (${keyList.join(', ')})`);
|
|
612
|
+
} else if (f === 'forge-widget.html') {
|
|
613
|
+
console.log(' ✓ forge-widget.html (copy <script> + <forge-chat> into your app)');
|
|
614
|
+
}
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
if (filesWritten.length === 0) {
|
|
618
|
+
console.log(' (no files written)');
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
console.log('\n Next steps:');
|
|
622
|
+
if (hasSidecar) {
|
|
623
|
+
console.log(' Start the sidecar: npx forge-service --mode=sidecar');
|
|
624
|
+
}
|
|
625
|
+
if (writeWidget) {
|
|
626
|
+
console.log(' Open the widget: open forge-widget.html');
|
|
627
|
+
}
|
|
628
|
+
if (mode === 'both' || mode === 'tui') {
|
|
629
|
+
console.log(' Build tools via TUI: npx forge');
|
|
630
|
+
}
|
|
631
|
+
console.log('────────────────────────────────────────────────────────────\n');
|
|
632
|
+
|
|
633
|
+
} finally {
|
|
634
|
+
if (ownRl) rl.close();
|
|
635
|
+
}
|
|
636
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Manual Entry — Add an API endpoint via interactive prompts.
|
|
3
|
+
* Used when the endpoint isn't in OpenAPI or manifest.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import * as readline from 'readline';
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* @typedef {Object} ApiEndpoint
|
|
10
|
+
* @property {string} path
|
|
11
|
+
* @property {string} method
|
|
12
|
+
* @property {string} name
|
|
13
|
+
* @property {string} description
|
|
14
|
+
* @property {Record<string,unknown>} [params]
|
|
15
|
+
* @property {boolean} [requiresConfirmation]
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Prompt for a single line.
|
|
20
|
+
* @param {readline.Interface} rl
|
|
21
|
+
* @param {string} question
|
|
22
|
+
* @param {string} [defaultValue]
|
|
23
|
+
* @returns {Promise<string>}
|
|
24
|
+
*/
|
|
25
|
+
function ask(rl, question, defaultValue = '') {
|
|
26
|
+
const suffix = defaultValue ? ` [${defaultValue}]` : '';
|
|
27
|
+
return new Promise((resolve) => {
|
|
28
|
+
rl.question(`${question}${suffix}: `, (ans) => {
|
|
29
|
+
resolve(ans.trim() || defaultValue);
|
|
30
|
+
});
|
|
31
|
+
});
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Add an endpoint via interactive prompts.
|
|
36
|
+
* @param {readline.Interface} [rl] - Reuse existing readline (e.g. from TUI)
|
|
37
|
+
* @returns {Promise<ApiEndpoint>}
|
|
38
|
+
*/
|
|
39
|
+
export async function addEndpointManually(rl) {
|
|
40
|
+
const ownRl = !rl;
|
|
41
|
+
if (!rl) rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
|
42
|
+
|
|
43
|
+
const path = await ask(rl, 'Path (e.g. /api/v1/holdings)', '/api/v1/example');
|
|
44
|
+
const method = (await ask(rl, 'Method (GET, POST, etc.)', 'GET')).toUpperCase();
|
|
45
|
+
const name = await ask(rl, 'Tool name (snake_case)', path.split('/').filter(Boolean).pop()?.replace(/-/g, '_') || 'get_example');
|
|
46
|
+
const description = await ask(rl, 'Description (routing contract)', `${method} ${path}`);
|
|
47
|
+
const confirmStr = await ask(rl, 'Requires confirmation? (y/n)', 'n');
|
|
48
|
+
const requiresConfirmation = /^y|yes|true$/i.test(confirmStr);
|
|
49
|
+
|
|
50
|
+
if (ownRl) rl.close();
|
|
51
|
+
|
|
52
|
+
return {
|
|
53
|
+
path: path.startsWith('/') ? path : `/${path}`,
|
|
54
|
+
method: method || 'GET',
|
|
55
|
+
name: name || 'get_example',
|
|
56
|
+
description: description || `${method} ${path}`,
|
|
57
|
+
requiresConfirmation
|
|
58
|
+
};
|
|
59
|
+
}
|