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.
Files changed (107) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +209 -0
  3. package/lib/agent-registry.js +170 -0
  4. package/lib/api-client.js +792 -0
  5. package/lib/api-loader.js +260 -0
  6. package/lib/auth.d.ts +25 -0
  7. package/lib/auth.js +158 -0
  8. package/lib/checks/check-adapter.js +172 -0
  9. package/lib/checks/compose.js +42 -0
  10. package/lib/checks/content-match.js +14 -0
  11. package/lib/checks/cost-budget.js +11 -0
  12. package/lib/checks/index.js +18 -0
  13. package/lib/checks/json-valid.js +15 -0
  14. package/lib/checks/latency.js +11 -0
  15. package/lib/checks/length-bounds.js +17 -0
  16. package/lib/checks/negative-match.js +14 -0
  17. package/lib/checks/no-hallucinated-numbers.js +63 -0
  18. package/lib/checks/non-empty.js +34 -0
  19. package/lib/checks/regex-match.js +12 -0
  20. package/lib/checks/run-checks.js +84 -0
  21. package/lib/checks/schema-match.js +26 -0
  22. package/lib/checks/tool-call-count.js +16 -0
  23. package/lib/checks/tool-selection.js +34 -0
  24. package/lib/checks/types.js +45 -0
  25. package/lib/comparison/compare.js +86 -0
  26. package/lib/comparison/format.js +104 -0
  27. package/lib/comparison/index.js +6 -0
  28. package/lib/comparison/statistics.js +59 -0
  29. package/lib/comparison/types.js +41 -0
  30. package/lib/config-schema.js +200 -0
  31. package/lib/config.d.ts +66 -0
  32. package/lib/conversation-store.d.ts +77 -0
  33. package/lib/conversation-store.js +443 -0
  34. package/lib/db.d.ts +6 -0
  35. package/lib/db.js +1112 -0
  36. package/lib/dep-check.js +99 -0
  37. package/lib/drift-background.js +61 -0
  38. package/lib/drift-monitor.js +187 -0
  39. package/lib/eval-runner.js +566 -0
  40. package/lib/fixtures/fixture-store.js +161 -0
  41. package/lib/fixtures/index.js +11 -0
  42. package/lib/forge-engine.js +982 -0
  43. package/lib/forge-eval-generator.js +417 -0
  44. package/lib/forge-file-writer.js +386 -0
  45. package/lib/forge-service-client.js +190 -0
  46. package/lib/forge-service.d.ts +4 -0
  47. package/lib/forge-service.js +655 -0
  48. package/lib/forge-verifier-generator.js +271 -0
  49. package/lib/handlers/admin.js +151 -0
  50. package/lib/handlers/agents.js +229 -0
  51. package/lib/handlers/chat-resume.js +334 -0
  52. package/lib/handlers/chat-sync.js +320 -0
  53. package/lib/handlers/chat.js +320 -0
  54. package/lib/handlers/conversations.js +92 -0
  55. package/lib/handlers/preferences.js +88 -0
  56. package/lib/handlers/tools-list.js +58 -0
  57. package/lib/hitl-engine.d.ts +60 -0
  58. package/lib/hitl-engine.js +261 -0
  59. package/lib/http-utils.js +92 -0
  60. package/lib/index.d.ts +20 -0
  61. package/lib/index.js +141 -0
  62. package/lib/init.js +636 -0
  63. package/lib/manual-entry.js +59 -0
  64. package/lib/mcp-server.js +252 -0
  65. package/lib/output-groups.js +54 -0
  66. package/lib/postgres-store.d.ts +31 -0
  67. package/lib/postgres-store.js +465 -0
  68. package/lib/preference-store.d.ts +47 -0
  69. package/lib/preference-store.js +79 -0
  70. package/lib/prompt-store.d.ts +42 -0
  71. package/lib/prompt-store.js +60 -0
  72. package/lib/rate-limiter.d.ts +30 -0
  73. package/lib/rate-limiter.js +104 -0
  74. package/lib/react-engine.d.ts +110 -0
  75. package/lib/react-engine.js +337 -0
  76. package/lib/runner/cli.js +156 -0
  77. package/lib/runner/cost-estimator.js +71 -0
  78. package/lib/runner/gate.js +46 -0
  79. package/lib/runner/index.js +165 -0
  80. package/lib/sidecar.d.ts +83 -0
  81. package/lib/sidecar.js +161 -0
  82. package/lib/sse.d.ts +15 -0
  83. package/lib/sse.js +30 -0
  84. package/lib/tools-scanner.js +91 -0
  85. package/lib/tui.js +253 -0
  86. package/lib/verifier-report.js +78 -0
  87. package/lib/verifier-runner.js +338 -0
  88. package/lib/verifier-scanner.js +70 -0
  89. package/lib/verifier-worker-pool.js +196 -0
  90. package/lib/views/chat.js +340 -0
  91. package/lib/views/endpoints.js +203 -0
  92. package/lib/views/eval-run.js +206 -0
  93. package/lib/views/forge-agent.js +538 -0
  94. package/lib/views/forge.js +410 -0
  95. package/lib/views/main-menu.js +275 -0
  96. package/lib/views/mediation.js +381 -0
  97. package/lib/views/model-compare.js +430 -0
  98. package/lib/views/model-comparison.js +333 -0
  99. package/lib/views/onboarding.js +470 -0
  100. package/lib/views/performance.js +237 -0
  101. package/lib/views/run-evals.js +205 -0
  102. package/lib/views/settings.js +829 -0
  103. package/lib/views/tools-evals.js +514 -0
  104. package/lib/views/verifier-coverage.js +617 -0
  105. package/lib/workers/verifier-worker.js +52 -0
  106. package/package.json +123 -0
  107. 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
+ }