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
|
@@ -0,0 +1,340 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Chat View — Interactive chat to test model connection and tool routing.
|
|
3
|
+
*
|
|
4
|
+
* - Loads API key (Anthropic or OpenAI) from .env
|
|
5
|
+
* - Loads tool definitions from toolsDir so the model sees the actual tools
|
|
6
|
+
* - Loads system prompt from config.systemPromptPath if set
|
|
7
|
+
* - Handles multi-turn tool calling: shows what was called, sends stub results back,
|
|
8
|
+
* then continues so you can see the model's final response
|
|
9
|
+
* - Tab: toggle focus between input and log (for scrolling history)
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import blessed from 'blessed';
|
|
13
|
+
import { existsSync, readFileSync } from 'fs';
|
|
14
|
+
import { resolve, dirname } from 'path';
|
|
15
|
+
import { fileURLToPath } from 'url';
|
|
16
|
+
import { llmTurn } from '../api-client.js';
|
|
17
|
+
|
|
18
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
19
|
+
const PROJECT_ROOT = resolve(__dirname, '../..');
|
|
20
|
+
|
|
21
|
+
// ── Helpers ────────────────────────────────────────────────────────────────
|
|
22
|
+
|
|
23
|
+
function loadEnv() {
|
|
24
|
+
const envPath = resolve(PROJECT_ROOT, '.env');
|
|
25
|
+
if (!existsSync(envPath)) return {};
|
|
26
|
+
const out = {};
|
|
27
|
+
for (const line of readFileSync(envPath, 'utf-8').split('\n')) {
|
|
28
|
+
const t = line.trim();
|
|
29
|
+
if (!t || t.startsWith('#')) continue;
|
|
30
|
+
const eq = t.indexOf('=');
|
|
31
|
+
if (eq === -1) continue;
|
|
32
|
+
out[t.slice(0, eq).trim()] = t.slice(eq + 1).trim().replace(/^["']|["']$/g, '');
|
|
33
|
+
}
|
|
34
|
+
return out;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const MAX_TOOL_DEPTH = 3;
|
|
38
|
+
|
|
39
|
+
// ── View ───────────────────────────────────────────────────────────────────
|
|
40
|
+
|
|
41
|
+
export function createView({ screen, content, config, navigate, setFooter, screenKey, openPopup, closePopup, startService }) {
|
|
42
|
+
const container = blessed.box({ top: 0, left: 0, width: '100%', height: '100%', tags: true });
|
|
43
|
+
|
|
44
|
+
// ── Info bar ──────────────────────────────────────────────────────────────
|
|
45
|
+
const infoBar = blessed.box({
|
|
46
|
+
parent: container,
|
|
47
|
+
top: 0, left: 0, width: '100%', height: 1, tags: true,
|
|
48
|
+
style: { bg: 'default' }
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
// ── Message log ───────────────────────────────────────────────────────────
|
|
52
|
+
const log = blessed.log({
|
|
53
|
+
parent: container,
|
|
54
|
+
top: 1, left: 0, width: '100%', height: '100%-5',
|
|
55
|
+
tags: true, scrollable: true, alwaysScroll: true,
|
|
56
|
+
keys: true, vi: false, mouse: true,
|
|
57
|
+
border: { type: 'line' },
|
|
58
|
+
style: { border: { fg: '#333333' }, focus: { border: { fg: 'cyan' } } },
|
|
59
|
+
scrollbar: { ch: '│', style: { fg: '#555555' } }
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
// ── Status bar (shows "Thinking..." etc.) ────────────────────────────────
|
|
63
|
+
const statusBar = blessed.box({
|
|
64
|
+
parent: container,
|
|
65
|
+
bottom: 3, left: 0, width: '100%', height: 1,
|
|
66
|
+
tags: true, style: { fg: '#888888' }
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
// ── Input box ─────────────────────────────────────────────────────────────
|
|
70
|
+
const inputBox = blessed.textbox({
|
|
71
|
+
parent: container,
|
|
72
|
+
bottom: 0, left: 0, width: '100%', height: 3,
|
|
73
|
+
border: { type: 'line' },
|
|
74
|
+
style: {
|
|
75
|
+
border: { fg: '#333333' },
|
|
76
|
+
focus: { border: { fg: 'cyan' } }
|
|
77
|
+
},
|
|
78
|
+
label: ' Message (Enter send, Esc shortcuts, Tab scroll) '
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
setFooter(
|
|
82
|
+
' {cyan-fg}Enter{/cyan-fg} send {cyan-fg}Esc{/cyan-fg} shortcuts ' +
|
|
83
|
+
'{cyan-fg}e{/cyan-fg} edit {cyan-fg}c{/cyan-fg} clear {cyan-fg}r{/cyan-fg} reset {cyan-fg}b{/cyan-fg} back'
|
|
84
|
+
);
|
|
85
|
+
|
|
86
|
+
// ── Explicit input mode management ──────────────────────────────────────
|
|
87
|
+
let inputActive = false;
|
|
88
|
+
|
|
89
|
+
function startInput() {
|
|
90
|
+
inputActive = true;
|
|
91
|
+
inputBox.focus();
|
|
92
|
+
inputBox.style.border = { fg: 'cyan' };
|
|
93
|
+
log.style.border = { fg: '#333333' };
|
|
94
|
+
screen.render();
|
|
95
|
+
inputBox.readInput((err, value) => {
|
|
96
|
+
inputActive = false;
|
|
97
|
+
if (err || value === undefined || value === null) {
|
|
98
|
+
// Escape — exit to command mode
|
|
99
|
+
log.focus();
|
|
100
|
+
log.style.border = { fg: 'cyan' };
|
|
101
|
+
inputBox.style.border = { fg: '#333333' };
|
|
102
|
+
screen.render();
|
|
103
|
+
return;
|
|
104
|
+
}
|
|
105
|
+
// Enter — submit
|
|
106
|
+
const text = (value || '').trim();
|
|
107
|
+
inputBox.clearValue();
|
|
108
|
+
screen.render();
|
|
109
|
+
if (text) {
|
|
110
|
+
sendMessage(text);
|
|
111
|
+
} else {
|
|
112
|
+
startInput();
|
|
113
|
+
}
|
|
114
|
+
});
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// ── Conversation state ────────────────────────────────────────────────────
|
|
118
|
+
let apiMessages = []; // provider-format message history
|
|
119
|
+
let busy = false;
|
|
120
|
+
let provider = null;
|
|
121
|
+
let apiKey = null;
|
|
122
|
+
let model = null;
|
|
123
|
+
let systemPrompt = '';
|
|
124
|
+
let tools = [];
|
|
125
|
+
let initialized = false;
|
|
126
|
+
|
|
127
|
+
// ── Init: load config, key, tools ─────────────────────────────────────────
|
|
128
|
+
async function init() {
|
|
129
|
+
const env = loadEnv();
|
|
130
|
+
if (env.ANTHROPIC_API_KEY) {
|
|
131
|
+
provider = 'anthropic';
|
|
132
|
+
apiKey = env.ANTHROPIC_API_KEY;
|
|
133
|
+
const configModel = config?.models?.generation || config?.model;
|
|
134
|
+
model = configModel?.startsWith('claude') ? configModel : 'claude-sonnet-4-6';
|
|
135
|
+
} else if (env.OPENAI_API_KEY) {
|
|
136
|
+
provider = 'openai';
|
|
137
|
+
apiKey = env.OPENAI_API_KEY;
|
|
138
|
+
const configModel = config?.models?.generation || config?.model;
|
|
139
|
+
model = configModel && !configModel.startsWith('claude') ? configModel : 'gpt-4o-mini';
|
|
140
|
+
} else {
|
|
141
|
+
infoBar.setContent(
|
|
142
|
+
' {red-fg}⚠ No API key{/red-fg} Add ANTHROPIC_API_KEY or OPENAI_API_KEY in Settings → API Keys'
|
|
143
|
+
);
|
|
144
|
+
screen.render();
|
|
145
|
+
startInput();
|
|
146
|
+
return;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// Load system prompt
|
|
150
|
+
if (config?.systemPromptPath) {
|
|
151
|
+
const sp = resolve(PROJECT_ROOT, config.systemPromptPath);
|
|
152
|
+
if (existsSync(sp)) {
|
|
153
|
+
try { systemPrompt = readFileSync(sp, 'utf-8'); } catch (_) { /* ignore */ }
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// Load tools
|
|
158
|
+
try {
|
|
159
|
+
const { getToolsForEval } = await import('../eval-runner.js');
|
|
160
|
+
tools = getToolsForEval(config);
|
|
161
|
+
} catch (_) { tools = []; }
|
|
162
|
+
|
|
163
|
+
infoBar.setContent(
|
|
164
|
+
` {cyan-fg}${model}{/cyan-fg} via {white-fg}${provider}{/white-fg}` +
|
|
165
|
+
` {#888888-fg}${tools.length} tool${tools.length !== 1 ? 's' : ''} loaded` +
|
|
166
|
+
`${systemPrompt ? ' system prompt active' : ''}{/#888888-fg}`
|
|
167
|
+
);
|
|
168
|
+
|
|
169
|
+
if (tools.length === 0) {
|
|
170
|
+
appendSystem('No tools loaded. Configure toolsDir in forge.config.json to test tool routing.');
|
|
171
|
+
} else {
|
|
172
|
+
appendSystem(`Tools available: ${tools.map((t) => t.name).join(', ')}`);
|
|
173
|
+
}
|
|
174
|
+
if (systemPrompt) appendSystem('System prompt loaded.');
|
|
175
|
+
appendSystem('Type a message and press Enter to chat.');
|
|
176
|
+
|
|
177
|
+
initialized = true;
|
|
178
|
+
screen.render();
|
|
179
|
+
startInput();
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// ── Log helpers ────────────────────────────────────────────────────────────
|
|
183
|
+
function appendSystem(text) {
|
|
184
|
+
log.log(`{#555555-fg}── ${text} ──{/#555555-fg}`);
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
function appendUser(text) {
|
|
188
|
+
log.log('');
|
|
189
|
+
log.log(`{cyan-fg}{bold}You:{/bold}{/cyan-fg} ${text}`);
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
function appendAssistant(text) {
|
|
193
|
+
if (!text.trim()) return;
|
|
194
|
+
log.log(`{green-fg}{bold}Model:{/bold}{/green-fg} ${text.replace(/\n/g, '\n ')}`);
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
function appendToolCall(name, input) {
|
|
198
|
+
const args = Object.keys(input).length
|
|
199
|
+
? ' ' + Object.entries(input).map(([k, v]) => `${k}=${JSON.stringify(v)}`).join(' ')
|
|
200
|
+
: '';
|
|
201
|
+
log.log(`{yellow-fg}🔧 called:{/yellow-fg} {bold}${name}{/bold}${args}`);
|
|
202
|
+
log.log(`{#555555-fg} ↳ stub result returned (no real execution){/#555555-fg}`);
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
function setStatus(text) {
|
|
206
|
+
statusBar.setContent(text ? ` {#888888-fg}${text}{/#888888-fg}` : '');
|
|
207
|
+
screen.render();
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
// ── API call with multi-turn tool handling ─────────────────────────────────
|
|
211
|
+
async function doTurn(depth = 0) {
|
|
212
|
+
if (depth >= MAX_TOOL_DEPTH) {
|
|
213
|
+
appendSystem('Max tool call depth reached.');
|
|
214
|
+
return;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
const turn = await llmTurn({
|
|
218
|
+
provider,
|
|
219
|
+
apiKey,
|
|
220
|
+
model,
|
|
221
|
+
system: systemPrompt,
|
|
222
|
+
messages: apiMessages,
|
|
223
|
+
tools,
|
|
224
|
+
maxTokens: 1024
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
// Show text response (might be a preamble before tool calls)
|
|
228
|
+
if (turn.text) appendAssistant(turn.text);
|
|
229
|
+
|
|
230
|
+
if (turn.toolCalls.length > 0) {
|
|
231
|
+
// Show each tool call
|
|
232
|
+
for (const tc of turn.toolCalls) appendToolCall(tc.name, tc.input || {});
|
|
233
|
+
|
|
234
|
+
if (provider === 'anthropic') {
|
|
235
|
+
// Append assistant turn (with tool_use blocks)
|
|
236
|
+
apiMessages.push({ role: 'assistant', content: turn.rawContent });
|
|
237
|
+
// Append stub tool results
|
|
238
|
+
apiMessages.push({
|
|
239
|
+
role: 'user',
|
|
240
|
+
content: turn.toolCalls.map((tc) => ({
|
|
241
|
+
type: 'tool_result',
|
|
242
|
+
tool_use_id: tc.id,
|
|
243
|
+
content: `Stub result for ${tc.name}. In production, this would return real data. Input was: ${JSON.stringify(tc.input)}`
|
|
244
|
+
}))
|
|
245
|
+
});
|
|
246
|
+
} else {
|
|
247
|
+
// OpenAI: append assistant message with tool_calls, then tool results
|
|
248
|
+
apiMessages.push({ role: 'assistant', ...turn.rawContent });
|
|
249
|
+
for (const tc of turn.toolCalls) {
|
|
250
|
+
apiMessages.push({
|
|
251
|
+
role: 'tool',
|
|
252
|
+
tool_call_id: tc.id,
|
|
253
|
+
content: `Stub result for ${tc.name}. Input: ${JSON.stringify(tc.input)}`
|
|
254
|
+
});
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
// Continue conversation to get the final text response
|
|
259
|
+
setStatus('Processing tool results…');
|
|
260
|
+
await doTurn(depth + 1);
|
|
261
|
+
} else {
|
|
262
|
+
// Final text-only response — add to history
|
|
263
|
+
if (provider === 'anthropic') {
|
|
264
|
+
apiMessages.push({ role: 'assistant', content: turn.rawContent });
|
|
265
|
+
} else {
|
|
266
|
+
if (turn.text) {
|
|
267
|
+
apiMessages.push({ role: 'assistant', content: turn.text });
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
async function sendMessage(text) {
|
|
274
|
+
if (busy) return;
|
|
275
|
+
if (!initialized) {
|
|
276
|
+
statusBar.setContent(' {yellow-fg}Not ready — add an API key in Settings → API Keys / Secrets{/yellow-fg}');
|
|
277
|
+
screen.render();
|
|
278
|
+
return;
|
|
279
|
+
}
|
|
280
|
+
busy = true;
|
|
281
|
+
|
|
282
|
+
appendUser(text);
|
|
283
|
+
|
|
284
|
+
apiMessages.push({ role: 'user', content: text });
|
|
285
|
+
|
|
286
|
+
setStatus('Waiting for response…');
|
|
287
|
+
try {
|
|
288
|
+
await doTurn();
|
|
289
|
+
} catch (err) {
|
|
290
|
+
appendSystem(`Error: ${err.message}`);
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
setStatus('');
|
|
294
|
+
log.log('');
|
|
295
|
+
busy = false;
|
|
296
|
+
screen.render();
|
|
297
|
+
startInput();
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
// ── Input handling (managed by startInput / readInput) ───────────────────
|
|
301
|
+
|
|
302
|
+
// e/i = enter input mode (vim-style)
|
|
303
|
+
screenKey(['e', 'i'], () => {
|
|
304
|
+
if (inputActive) return;
|
|
305
|
+
startInput();
|
|
306
|
+
});
|
|
307
|
+
|
|
308
|
+
// Tab: toggle focus between input and log
|
|
309
|
+
screenKey('tab', () => {
|
|
310
|
+
if (inputActive) {
|
|
311
|
+
inputBox.cancel();
|
|
312
|
+
} else {
|
|
313
|
+
startInput();
|
|
314
|
+
}
|
|
315
|
+
});
|
|
316
|
+
|
|
317
|
+
// c = clear log (only in command mode)
|
|
318
|
+
screenKey('c', () => {
|
|
319
|
+
if (inputActive) return;
|
|
320
|
+
log.setContent('');
|
|
321
|
+
appendSystem('Log cleared.');
|
|
322
|
+
screen.render();
|
|
323
|
+
});
|
|
324
|
+
|
|
325
|
+
// r = reset conversation (only in command mode)
|
|
326
|
+
screenKey('r', () => {
|
|
327
|
+
if (inputActive) return;
|
|
328
|
+
apiMessages = [];
|
|
329
|
+
log.setContent('');
|
|
330
|
+
appendSystem('Conversation reset.');
|
|
331
|
+
screen.render();
|
|
332
|
+
});
|
|
333
|
+
|
|
334
|
+
container.refresh = () => { /* no-op; chat state is live */ };
|
|
335
|
+
|
|
336
|
+
// Defer init so tui.js can append container to the screen before log.log() writes
|
|
337
|
+
setImmediate(() => { init(); });
|
|
338
|
+
return container;
|
|
339
|
+
}
|
|
340
|
+
|
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Endpoints View — All endpoints with tool coverage status.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import blessed from 'blessed';
|
|
6
|
+
import { existsSync, readFileSync, writeFileSync } from 'fs';
|
|
7
|
+
import { resolve, dirname } from 'path';
|
|
8
|
+
import { fileURLToPath } from 'url';
|
|
9
|
+
import { loadApis } from '../api-loader.js';
|
|
10
|
+
import { getExistingTools } from '../tools-scanner.js';
|
|
11
|
+
|
|
12
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
13
|
+
const PROJECT_ROOT = resolve(__dirname, '../..');
|
|
14
|
+
const LOCK_FILE = resolve(PROJECT_ROOT, '.forge-service.lock');
|
|
15
|
+
const PENDING_SPEC_FILE = resolve(PROJECT_ROOT, 'forge-pending-tool.json');
|
|
16
|
+
|
|
17
|
+
function readLock() {
|
|
18
|
+
if (!existsSync(LOCK_FILE)) return null;
|
|
19
|
+
try { return JSON.parse(readFileSync(LOCK_FILE, 'utf-8')); } catch (_) { return null; }
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
async function httpJson(method, path, port, body) {
|
|
23
|
+
const { request } = await import('http');
|
|
24
|
+
return new Promise((res, rej) => {
|
|
25
|
+
const payload = body ? JSON.stringify(body) : undefined;
|
|
26
|
+
const req = request({
|
|
27
|
+
hostname: '127.0.0.1', port, path, method,
|
|
28
|
+
headers: { 'Content-Type': 'application/json', ...(payload ? { 'Content-Length': Buffer.byteLength(payload) } : {}) }
|
|
29
|
+
}, (r) => {
|
|
30
|
+
let d = '';
|
|
31
|
+
r.on('data', (c) => { d += c; });
|
|
32
|
+
r.on('end', () => res({ status: r.statusCode, body: d }));
|
|
33
|
+
});
|
|
34
|
+
req.setTimeout(5000, () => { req.destroy(); rej(new Error('timeout')); });
|
|
35
|
+
req.on('error', rej);
|
|
36
|
+
if (payload) req.write(payload);
|
|
37
|
+
req.end();
|
|
38
|
+
});
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
async function loadData(config) {
|
|
42
|
+
const project = config?.project || {};
|
|
43
|
+
const api = config?.api || {};
|
|
44
|
+
const tools = getExistingTools(project);
|
|
45
|
+
const toolSet = new Set(tools.map((t) => t.toLowerCase().replace(/-/g, '_')));
|
|
46
|
+
const hasApiConfig = !!(api.manifestPath || api.discovery?.url || api.discovery?.file);
|
|
47
|
+
let endpoints = [];
|
|
48
|
+
if (hasApiConfig) {
|
|
49
|
+
endpoints = await loadApis(api); // let errors propagate to the view's catch
|
|
50
|
+
}
|
|
51
|
+
return {
|
|
52
|
+
hasApiConfig,
|
|
53
|
+
endpoints: endpoints.map((e) => ({
|
|
54
|
+
method: e.method || 'GET',
|
|
55
|
+
path: e.path || '',
|
|
56
|
+
toolName: e.name || '—',
|
|
57
|
+
covered: toolSet.has((e.name || '').toLowerCase().replace(/-/g, '_'))
|
|
58
|
+
}))
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function createView({ screen, content, config, navigate, setFooter, screenKey, openPopup, closePopup }) {
|
|
63
|
+
const container = blessed.box({ top: 0, left: 0, width: '100%', height: '100%', tags: true });
|
|
64
|
+
|
|
65
|
+
const table = blessed.listtable({
|
|
66
|
+
parent: container,
|
|
67
|
+
top: 0, left: 0, width: '100%', height: '100%-1',
|
|
68
|
+
tags: true, keys: true, vi: true, mouse: true,
|
|
69
|
+
border: { type: 'line' }, align: 'left',
|
|
70
|
+
style: {
|
|
71
|
+
header: { bold: true, fg: 'cyan' },
|
|
72
|
+
cell: { selected: { bg: '#1a3a5c', fg: 'white' } },
|
|
73
|
+
border: { fg: '#333333' }
|
|
74
|
+
},
|
|
75
|
+
pad: 1
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
const statusBar = blessed.box({
|
|
79
|
+
parent: container,
|
|
80
|
+
bottom: 0, left: 0, width: '100%', height: 1, tags: true
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
setFooter(
|
|
84
|
+
' {cyan-fg}↑↓{/cyan-fg} navigate {cyan-fg}a{/cyan-fg} add manually ' +
|
|
85
|
+
'{cyan-fg}r{/cyan-fg} refresh {cyan-fg}b{/cyan-fg} back'
|
|
86
|
+
);
|
|
87
|
+
|
|
88
|
+
async function enqueueAndReport(endpoint) {
|
|
89
|
+
const lock = readLock();
|
|
90
|
+
if (lock) {
|
|
91
|
+
try {
|
|
92
|
+
const enqRes = await httpJson('POST', '/enqueue', lock.port, { endpoint });
|
|
93
|
+
const enqData = JSON.parse(enqRes.body);
|
|
94
|
+
const healthRes = await httpJson('GET', '/health', lock.port, null);
|
|
95
|
+
const health = JSON.parse(healthRes.body);
|
|
96
|
+
const watching = (health.waiting ?? 0) > 0;
|
|
97
|
+
statusBar.setContent(watching
|
|
98
|
+
? ` {green-fg}✓ Queued (pos ${enqData.position}) — Claude is watching{/green-fg}`
|
|
99
|
+
: ` {yellow-fg}⏳ Queued (pos ${enqData.position}) — run /forge-tool in Claude to process{/yellow-fg}`
|
|
100
|
+
);
|
|
101
|
+
} catch (err) {
|
|
102
|
+
try {
|
|
103
|
+
writeFileSync(PENDING_SPEC_FILE, JSON.stringify(
|
|
104
|
+
{ _source: 'forge-api-tui', _createdAt: new Date().toISOString(), endpoint, project: config?.project || {} },
|
|
105
|
+
null, 2
|
|
106
|
+
), 'utf-8');
|
|
107
|
+
statusBar.setContent(` {yellow-fg}Queue error — wrote forge-pending-tool.json instead{/yellow-fg}`);
|
|
108
|
+
} catch (_) {
|
|
109
|
+
statusBar.setContent(` {red-fg}Error: ${err.message}{/red-fg}`);
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
} else {
|
|
113
|
+
writeFileSync(PENDING_SPEC_FILE, JSON.stringify(
|
|
114
|
+
{ _source: 'forge-api-tui', _createdAt: new Date().toISOString(), endpoint, project: config?.project || {} },
|
|
115
|
+
null, 2
|
|
116
|
+
), 'utf-8');
|
|
117
|
+
statusBar.setContent(
|
|
118
|
+
' {yellow-fg}No forge service — wrote forge-pending-tool.json. Run /forge-tool in Claude to process.{/yellow-fg}'
|
|
119
|
+
);
|
|
120
|
+
}
|
|
121
|
+
screen.render();
|
|
122
|
+
await container.refresh();
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
table.key('a', () => showManualAddPrompt(screen, openPopup, closePopup, enqueueAndReport));
|
|
126
|
+
|
|
127
|
+
container.refresh = async () => {
|
|
128
|
+
try {
|
|
129
|
+
const { rows, hasApiConfig } = await loadData(config).then((d) => ({ rows: d.endpoints, hasApiConfig: d.hasApiConfig }));
|
|
130
|
+
if (!hasApiConfig) {
|
|
131
|
+
table.setData([['Method', 'Path', 'Tool Name', 'Status'],
|
|
132
|
+
['{yellow-fg}No API source configured{/yellow-fg}', 'Go to Settings → Configure API Source', '', '']]);
|
|
133
|
+
} else if (rows.length === 0) {
|
|
134
|
+
table.setData([['Method', 'Path', 'Tool Name', 'Status'], ['No endpoints found', '', '', '']]);
|
|
135
|
+
} else {
|
|
136
|
+
table.setData([
|
|
137
|
+
['Method', 'Path', 'Tool Name', 'Status'],
|
|
138
|
+
...rows.map((r) => [
|
|
139
|
+
r.method, r.path, r.toolName,
|
|
140
|
+
r.covered ? '{green-fg}✓ covered{/green-fg}' : '{yellow-fg}○ uncovered{/yellow-fg}'
|
|
141
|
+
])
|
|
142
|
+
]);
|
|
143
|
+
}
|
|
144
|
+
} catch (err) {
|
|
145
|
+
table.setData([['Method', 'Path', 'Tool Name', 'Status'], ['Error: ' + err.message, '', '', '']]);
|
|
146
|
+
}
|
|
147
|
+
screen.render();
|
|
148
|
+
};
|
|
149
|
+
|
|
150
|
+
container.refresh();
|
|
151
|
+
table.focus();
|
|
152
|
+
return container;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function showManualAddPrompt(screen, openPopup, closePopup, onAdd) {
|
|
156
|
+
const form = blessed.form({
|
|
157
|
+
parent: screen, border: 'line', height: 12, width: 60,
|
|
158
|
+
top: 'center', left: 'center', label: ' Add Endpoint Manually ', keys: true, tags: true
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
blessed.text({ parent: form, top: 1, left: 2, content: 'Method: ' });
|
|
162
|
+
const methodInput = blessed.textbox({
|
|
163
|
+
parent: form, top: 1, left: 10, width: 10, height: 1, inputOnFocus: true,
|
|
164
|
+
style: { fg: 'white', bg: 'blue' }
|
|
165
|
+
});
|
|
166
|
+
blessed.text({ parent: form, top: 3, left: 2, content: 'Path: ' });
|
|
167
|
+
const pathInput = blessed.textbox({
|
|
168
|
+
parent: form, top: 3, left: 10, width: 40, height: 1, inputOnFocus: true,
|
|
169
|
+
style: { fg: 'white', bg: 'blue' }
|
|
170
|
+
});
|
|
171
|
+
blessed.text({ parent: form, top: 5, left: 2, content: 'Name: ' });
|
|
172
|
+
const nameInput = blessed.textbox({
|
|
173
|
+
parent: form, top: 5, left: 10, width: 40, height: 1, inputOnFocus: true,
|
|
174
|
+
style: { fg: 'white', bg: 'blue' }
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
const submitBtn = blessed.button({
|
|
178
|
+
parent: form, top: 8, left: 2, width: 10, height: 1, content: ' Submit ',
|
|
179
|
+
style: { bg: 'green', fg: 'white', focus: { bg: 'blue' } }, keys: true, mouse: true
|
|
180
|
+
});
|
|
181
|
+
const cancelBtn = blessed.button({
|
|
182
|
+
parent: form, top: 8, left: 14, width: 10, height: 1, content: ' Cancel ',
|
|
183
|
+
style: { bg: 'red', fg: 'white', focus: { bg: 'blue' } }, keys: true, mouse: true
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
openPopup?.();
|
|
187
|
+
submitBtn.on('press', () => {
|
|
188
|
+
const endpoint = {
|
|
189
|
+
method: methodInput.getValue().toUpperCase() || 'GET',
|
|
190
|
+
path: pathInput.getValue() || '/',
|
|
191
|
+
name: nameInput.getValue() || 'unnamed_tool'
|
|
192
|
+
};
|
|
193
|
+
closePopup?.();
|
|
194
|
+
form.destroy();
|
|
195
|
+
screen.render();
|
|
196
|
+
Promise.resolve(onAdd(endpoint)).catch(() => {});
|
|
197
|
+
});
|
|
198
|
+
cancelBtn.on('press', () => { closePopup?.(); form.destroy(); screen.render(); });
|
|
199
|
+
form.key(['escape'], () => { closePopup?.(); form.destroy(); screen.render(); });
|
|
200
|
+
methodInput.focus();
|
|
201
|
+
screen.render();
|
|
202
|
+
}
|
|
203
|
+
|