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
@@ -0,0 +1,261 @@
1
+ /**
2
+ * HITL Engine — pause/resume for confirmable tool calls.
3
+ *
4
+ * Sensitivity levels:
5
+ * autonomous — never pause
6
+ * cautious — pause if tool spec has requiresConfirmation flag
7
+ * standard — pause for POST/PUT/PATCH/DELETE methods
8
+ * paranoid — always pause
9
+ *
10
+ * Storage backends:
11
+ * memory — in-process Map (default, single-instance only)
12
+ * sqlite — hitl_pending table (single-instance, survives restart)
13
+ * redis — forge:hitl:{token} keys with TTL (multi-instance, recommended for production)
14
+ *
15
+ * Paused state has a TTL — expired states cannot be resumed.
16
+ */
17
+
18
+ import { randomUUID } from 'crypto';
19
+
20
+ const DEFAULT_TTL_MS = 5 * 60 * 1000; // 5 minutes
21
+ const REDIS_KEY_PREFIX = 'forge:hitl:';
22
+
23
+ export class HitlEngine {
24
+ /**
25
+ * @param {object} opts
26
+ * @param {import('better-sqlite3').Database} [opts.db] — SQLite backend
27
+ * @param {object} [opts.redis] — Redis client instance (ioredis or node-redis compatible)
28
+ * @param {import('pg').Pool} [opts.pgPool] — Postgres pool instance
29
+ * @param {number} [opts.ttlMs] — pause state TTL (default 5 min)
30
+ */
31
+ constructor(opts = {}) {
32
+ this._db = opts.db ?? null;
33
+ this._redis = opts.redis ?? null;
34
+ this._pgPool = opts.pgPool ?? null;
35
+ this._ttlMs = opts.ttlMs ?? DEFAULT_TTL_MS;
36
+
37
+ // In-memory store as fallback when no DB, no Redis, and no Postgres
38
+ this._memStore = new Map();
39
+
40
+ // Periodic cleanup of expired in-memory entries (every 60s)
41
+ if (!this._db && !this._redis && !this._pgPool) {
42
+ this._cleanupTimer = setInterval(() => {
43
+ const now = Date.now();
44
+ for (const [key, entry] of this._memStore) {
45
+ if (now > entry.expiresAt) this._memStore.delete(key);
46
+ }
47
+ }, 60_000);
48
+ this._cleanupTimer.unref();
49
+ }
50
+
51
+ // Ensure hitl_pending table exists if using SQLite (and not Redis/Postgres)
52
+ if (this._db && !this._redis && !this._pgPool) {
53
+ this._db.exec(`
54
+ CREATE TABLE IF NOT EXISTS hitl_pending (
55
+ resume_token TEXT PRIMARY KEY,
56
+ state_json TEXT NOT NULL,
57
+ expires_at TEXT NOT NULL,
58
+ created_at TEXT NOT NULL
59
+ )
60
+ `);
61
+
62
+ // SQLite path — cleanup expired rows every 5 minutes.
63
+ // DESIGN NOTE: 5-minute interval chosen deliberately. A shorter interval (e.g. 60s)
64
+ // risks write contention under high HITL volume; a longer one (e.g. 30min) lets
65
+ // stale rows accumulate more. 5min is a conservative middle ground.
66
+ // If you see write contention on hitl_pending at scale, reduce to 60s.
67
+ const db = this._db;
68
+ this._sqliteCleanupTimer = setInterval(() => {
69
+ try {
70
+ db.prepare('DELETE FROM hitl_pending WHERE expires_at < ?')
71
+ .run(new Date().toISOString());
72
+ } catch { /* cleanup failure is non-fatal */ }
73
+ }, 5 * 60_000);
74
+ this._sqliteCleanupTimer.unref();
75
+ }
76
+
77
+ // Postgres table creation is deferred to first use (_ensurePgTable)
78
+ this._pgTableReady = false;
79
+ }
80
+
81
+ /** @private */
82
+ async _ensurePgTable() {
83
+ if (this._pgTableReady) return;
84
+ await this._pgPool.query(`
85
+ CREATE TABLE IF NOT EXISTS hitl_pending (
86
+ resume_token TEXT PRIMARY KEY,
87
+ state_json TEXT NOT NULL,
88
+ expires_at TEXT NOT NULL,
89
+ created_at TEXT NOT NULL
90
+ )
91
+ `);
92
+ this._pgTableReady = true;
93
+ }
94
+
95
+ /**
96
+ * Determine whether a tool call should pause for confirmation.
97
+ *
98
+ * @param {string} hitlLevel — user's HITL sensitivity level
99
+ * @param {object} toolSpec — { name, method?, requiresConfirmation? }
100
+ * @returns {boolean}
101
+ */
102
+ shouldPause(hitlLevel, toolSpec = {}) {
103
+ switch (hitlLevel) {
104
+ case 'autonomous':
105
+ return false;
106
+ case 'cautious':
107
+ return !!toolSpec.requiresConfirmation;
108
+ case 'standard': {
109
+ const method = (toolSpec.method || 'GET').toUpperCase();
110
+ return ['POST', 'PUT', 'PATCH', 'DELETE'].includes(method);
111
+ }
112
+ case 'paranoid':
113
+ return true;
114
+ default:
115
+ return false;
116
+ }
117
+ }
118
+
119
+ /**
120
+ * Store paused state and return a resume token.
121
+ *
122
+ * @param {object} state — arbitrary state to store (conversation, pending tool calls, etc.)
123
+ * @returns {Promise<string>} resumeToken
124
+ */
125
+ async pause(state) {
126
+ const resumeToken = randomUUID();
127
+ const stateJson = JSON.stringify(state);
128
+
129
+ if (this._redis) {
130
+ const ttlSeconds = Math.ceil(this._ttlMs / 1000);
131
+ await this._redis.set(
132
+ REDIS_KEY_PREFIX + resumeToken,
133
+ stateJson,
134
+ 'EX',
135
+ ttlSeconds
136
+ );
137
+ return resumeToken;
138
+ }
139
+
140
+ if (this._pgPool) {
141
+ await this._ensurePgTable();
142
+ const expiresAt = new Date(Date.now() + this._ttlMs).toISOString();
143
+ await this._pgPool.query(
144
+ `INSERT INTO hitl_pending (resume_token, state_json, expires_at, created_at)
145
+ VALUES ($1, $2, $3, $4)`,
146
+ [resumeToken, stateJson, expiresAt, new Date().toISOString()]
147
+ );
148
+ return resumeToken;
149
+ }
150
+
151
+ if (this._db) {
152
+ const expiresAt = new Date(Date.now() + this._ttlMs).toISOString();
153
+ this._db.prepare(`
154
+ INSERT INTO hitl_pending (resume_token, state_json, expires_at, created_at)
155
+ VALUES (?, ?, ?, ?)
156
+ `).run(resumeToken, stateJson, expiresAt, new Date().toISOString());
157
+ return resumeToken;
158
+ }
159
+
160
+ // In-memory fallback
161
+ this._memStore.set(resumeToken, { state, expiresAt: Date.now() + this._ttlMs });
162
+ return resumeToken;
163
+ }
164
+
165
+ /**
166
+ * Resume a paused state. Returns the state if valid, null if expired or not found.
167
+ * Deletes the state on successful resume (one-time use).
168
+ *
169
+ * @param {string} resumeToken
170
+ * @returns {Promise<object|null>}
171
+ */
172
+ async resume(resumeToken) {
173
+ if (this._redis) {
174
+ const key = REDIS_KEY_PREFIX + resumeToken;
175
+ // Atomic get-and-delete: use a pipeline or multi/exec
176
+ // For simplicity, GET then DEL — race window is acceptable for HITL
177
+ const stateJson = await this._redis.get(key);
178
+ if (!stateJson) return null;
179
+ await this._redis.del(key);
180
+ try {
181
+ return JSON.parse(stateJson);
182
+ } catch {
183
+ return null;
184
+ }
185
+ }
186
+
187
+ if (this._pgPool) {
188
+ await this._ensurePgTable();
189
+ const { rows } = await this._pgPool.query(
190
+ 'SELECT * FROM hitl_pending WHERE resume_token = $1',
191
+ [resumeToken]
192
+ );
193
+ if (rows.length === 0) return null;
194
+
195
+ const row = rows[0];
196
+ // Delete regardless (one-time use)
197
+ await this._pgPool.query(
198
+ 'DELETE FROM hitl_pending WHERE resume_token = $1',
199
+ [resumeToken]
200
+ );
201
+
202
+ // Check expiry
203
+ if (new Date(row.expires_at) < new Date()) return null;
204
+
205
+ try {
206
+ return JSON.parse(row.state_json);
207
+ } catch {
208
+ return null;
209
+ }
210
+ }
211
+
212
+ if (this._db) {
213
+ const row = this._db.prepare(
214
+ 'SELECT * FROM hitl_pending WHERE resume_token = ?'
215
+ ).get(resumeToken);
216
+
217
+ if (!row) return null;
218
+
219
+ // Delete regardless (one-time use)
220
+ this._db.prepare('DELETE FROM hitl_pending WHERE resume_token = ?').run(resumeToken);
221
+
222
+ // Check expiry
223
+ if (new Date(row.expires_at) < new Date()) return null;
224
+
225
+ try {
226
+ return JSON.parse(row.state_json);
227
+ } catch {
228
+ return null;
229
+ }
230
+ }
231
+
232
+ // In-memory fallback
233
+ const entry = this._memStore.get(resumeToken);
234
+ if (!entry) return null;
235
+ this._memStore.delete(resumeToken);
236
+ if (Date.now() > entry.expiresAt) return null;
237
+ return entry.state;
238
+ }
239
+ }
240
+
241
+ /**
242
+ * Factory — selects storage backend from config.
243
+ * Priority: Redis → Postgres → SQLite → in-memory.
244
+ *
245
+ * @param {object} config — forge config
246
+ * @param {import('better-sqlite3').Database} [db]
247
+ * @param {object} [redis] — pre-created Redis client instance
248
+ * @param {import('pg').Pool} [pgPool] — pre-created Postgres pool instance
249
+ * @returns {HitlEngine}
250
+ */
251
+ export function makeHitlEngine(config, db, redis, pgPool) {
252
+ const ttlMs = config?.hitl?.ttlMs ?? config?.ttlMs ?? undefined;
253
+ const ttlOpt = ttlMs !== undefined ? { ttlMs } : {};
254
+ if (redis) {
255
+ return new HitlEngine({ redis, ...ttlOpt });
256
+ }
257
+ if (pgPool) {
258
+ return new HitlEngine({ pgPool, ...ttlOpt });
259
+ }
260
+ return new HitlEngine({ db, ...ttlOpt });
261
+ }
@@ -0,0 +1,92 @@
1
+ /**
2
+ * Shared HTTP helpers for sidecar request handlers.
3
+ */
4
+
5
+ import { getAllToolRegistry } from './db.js';
6
+
7
+ const MAX_BODY_SIZE = 1_048_576; // 1 MB
8
+
9
+ /**
10
+ * Read and JSON-parse a request body. Returns {} on parse failure.
11
+ * Rejects bodies larger than MAX_BODY_SIZE.
12
+ * @param {import('http').IncomingMessage} req
13
+ * @returns {Promise<object>}
14
+ */
15
+ export function readBody(req) {
16
+ return new Promise((resolve, reject) => {
17
+ let data = '';
18
+ let size = 0;
19
+ req.on('data', (chunk) => {
20
+ size += chunk.length;
21
+ if (size > MAX_BODY_SIZE) {
22
+ req.destroy();
23
+ reject(new Error('Request body too large'));
24
+ return;
25
+ }
26
+ data += chunk;
27
+ });
28
+ req.on('end', () => {
29
+ try { resolve(data ? JSON.parse(data) : {}); } catch { resolve({}); }
30
+ });
31
+ req.on('error', () => resolve({}));
32
+ });
33
+ }
34
+
35
+ /**
36
+ * Send a JSON response with the given status code.
37
+ * @param {import('http').ServerResponse} res
38
+ * @param {number} statusCode
39
+ * @param {object} body
40
+ */
41
+ export function sendJson(res, statusCode, body) {
42
+ const payload = JSON.stringify(body);
43
+ res.writeHead(statusCode, { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(payload) });
44
+ res.end(payload);
45
+ }
46
+
47
+ /**
48
+ * Extract a JWT from an HTTP request.
49
+ * Checks Authorization: Bearer <token> first, then falls back to ?token= query param.
50
+ * @param {import('http').IncomingMessage} req
51
+ * @returns {string|null}
52
+ */
53
+ export function extractJwt(req) {
54
+ const auth = req.headers.authorization ?? '';
55
+ if (auth.startsWith('Bearer ')) return auth.slice(7) || null;
56
+ try {
57
+ return new URL(req.url, 'http://localhost').searchParams.get('token') || null;
58
+ } catch { return null; }
59
+ }
60
+
61
+ /**
62
+ * Load promoted tools from the tool registry and convert to LLM-format tool defs.
63
+ * @param {import('better-sqlite3').Database} db
64
+ * @param {string|string[]} [allowlist='*'] — '*' for all, or array of tool_names to include
65
+ * @returns {{ toolRows: object[], tools: object[] }}
66
+ */
67
+ export function loadPromotedTools(db, allowlist = '*') {
68
+ let toolRows = getAllToolRegistry(db).filter(r => r.lifecycle_state === 'promoted');
69
+ if (Array.isArray(allowlist)) {
70
+ const allowSet = new Set(allowlist);
71
+ toolRows = toolRows.filter(r => allowSet.has(r.tool_name));
72
+ }
73
+ const tools = [];
74
+ for (const row of toolRows) {
75
+ try {
76
+ const spec = JSON.parse(row.spec_json);
77
+ const schema = spec.schema || {};
78
+ const properties = {};
79
+ const required = [];
80
+ for (const [k, v] of Object.entries(schema)) {
81
+ properties[k] = { type: v.type || 'string', description: v.description || k };
82
+ if (!v.optional) required.push(k);
83
+ }
84
+ tools.push({
85
+ name: spec.name || row.tool_name,
86
+ description: spec.description || '',
87
+ inputSchema: { type: 'object', properties, required }
88
+ });
89
+ } catch { /* skip malformed specs */ }
90
+ }
91
+ return { toolRows, tools };
92
+ }
package/lib/index.d.ts ADDED
@@ -0,0 +1,20 @@
1
+ // Barrel re-export of all public types for `import type { ... } from 'agent-tool-forge'`
2
+ export * from './sidecar.js';
3
+ export type { AuthResult, AuthConfig, Authenticator, AdminAuthResult } from './auth.js';
4
+ export type { ConversationMessage, SessionSummary, ConversationStore } from './conversation-store.js';
5
+ export type {
6
+ ReactEvent, ReactLoopParams, ReactEventType,
7
+ TextEvent, TextDeltaEvent, ToolCallEvent, ToolResultEvent,
8
+ ToolWarningEvent, HitlEvent, ErrorEvent, DoneEvent
9
+ } from './react-engine.js';
10
+ export type {
11
+ SidecarConfig, AgentConfig, RateLimitConfig, VerificationConfig,
12
+ ConversationConfig, DatabaseConfig, AuthConfig as SidecarAuthConfig
13
+ } from './config-schema.js';
14
+ export type { HitlEngine, HitlEngineOptions, HitlLevel, HitlToolSpec } from './hitl-engine.js';
15
+ export type { PromptStore, PromptVersion } from './prompt-store.js';
16
+ export type { PreferenceStore, UserPreferences, EffectiveSettings } from './preference-store.js';
17
+ export type { RateLimiter, RateLimitResult } from './rate-limiter.js';
18
+ export type { PostgresStore } from './postgres-store.js';
19
+ export type { Db } from './db.js';
20
+ export type { SSEHandle } from './sse.js';
package/lib/index.js ADDED
@@ -0,0 +1,141 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Forge CLI — Entry point.
4
+ *
5
+ * Usage:
6
+ * node lib/index.js # Full-screen TUI
7
+ * node lib/index.js --manual # Skip to manual endpoint entry (fallback)
8
+ */
9
+
10
+ import { readFileSync, existsSync, writeFileSync } from 'fs';
11
+ import { resolve, dirname } from 'path';
12
+ import { fileURLToPath } from 'url';
13
+ import { runTui } from './tui.js';
14
+ import { addEndpointManually } from './manual-entry.js';
15
+ import * as readline from 'readline';
16
+
17
+ const CONFIG_FILE = 'forge.config.json';
18
+ const PENDING_SPEC_FILE = 'forge-pending-tool.json';
19
+
20
+ function findProjectRoot() {
21
+ return resolve(dirname(fileURLToPath(import.meta.url)), '..');
22
+ }
23
+
24
+ function loadConfig() {
25
+ const projectRoot = findProjectRoot();
26
+ const configPath = resolve(projectRoot, CONFIG_FILE);
27
+ if (!existsSync(configPath)) {
28
+ console.error(`No ${CONFIG_FILE} found in ${projectRoot}.\nRun "forge init" to set up your project, or create one from config/forge.config.template.json`);
29
+ process.exit(1);
30
+ }
31
+ const raw = readFileSync(configPath, 'utf-8');
32
+ try {
33
+ return JSON.parse(raw);
34
+ } catch (err) {
35
+ console.error(`${CONFIG_FILE} contains invalid JSON: ${err.message}`);
36
+ process.exit(1);
37
+ }
38
+ }
39
+
40
+ /**
41
+ * Returns true if the user needs to go through onboarding:
42
+ * - No API key in .env AND no API key in environment variables AND no key in config
43
+ */
44
+ function needsOnboarding(config) {
45
+ const projectRoot = findProjectRoot();
46
+ const envPath = resolve(projectRoot, '.env');
47
+
48
+ // Check process env first
49
+ if (process.env.ANTHROPIC_API_KEY || process.env.OPENAI_API_KEY ||
50
+ process.env.GOOGLE_API_KEY || process.env.GEMINI_API_KEY) return false;
51
+
52
+ // Check .env file
53
+ if (existsSync(envPath)) {
54
+ try {
55
+ const envText = readFileSync(envPath, 'utf-8');
56
+ if (/ANTHROPIC_API_KEY\s*=\s*\S/.test(envText)) return false;
57
+ if (/OPENAI_API_KEY\s*=\s*\S/.test(envText)) return false;
58
+ if (/GOOGLE_API_KEY\s*=\s*\S/.test(envText)) return false;
59
+ if (/GEMINI_API_KEY\s*=\s*\S/.test(envText)) return false;
60
+ } catch (_) { /* ignore */ }
61
+ }
62
+
63
+ // Check config object for configured credentials
64
+ if (config?.auth?.signingKey && typeof config.auth.signingKey === 'string' &&
65
+ config.auth.signingKey.trim() && !config.auth.signingKey.startsWith('${')) {
66
+ return false;
67
+ }
68
+ if (config?.adminKey && typeof config.adminKey === 'string' &&
69
+ config.adminKey.trim() && !config.adminKey.startsWith('${')) {
70
+ return false;
71
+ }
72
+
73
+ return true; // No key found anywhere
74
+ }
75
+
76
+ async function main() {
77
+ process.chdir(findProjectRoot());
78
+
79
+ const args = process.argv.slice(2);
80
+
81
+ if (args[0] === 'run') {
82
+ const { runCli } = await import('./runner/cli.js');
83
+ await runCli(args.slice(1));
84
+ return;
85
+ }
86
+
87
+ if (args[0] === 'init') {
88
+ const { runInit } = await import('./init.js');
89
+ await runInit();
90
+ return;
91
+ }
92
+
93
+ const manualOnly = args.includes('--manual') || args.includes('-m');
94
+
95
+ if (manualOnly) {
96
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
97
+ let endpoint;
98
+ try {
99
+ endpoint = await addEndpointManually(rl);
100
+ } finally {
101
+ rl.close();
102
+ }
103
+
104
+ const projectRoot = findProjectRoot();
105
+ let config;
106
+ if (existsSync(resolve(projectRoot, CONFIG_FILE))) {
107
+ try {
108
+ config = JSON.parse(readFileSync(resolve(projectRoot, CONFIG_FILE), 'utf-8'));
109
+ } catch (err) {
110
+ console.error('Error reading forge.config.json:', err.message);
111
+ config = {};
112
+ }
113
+ } else {
114
+ config = { project: {} };
115
+ }
116
+
117
+ const pendingSpec = {
118
+ _source: 'forge-api-tui',
119
+ _createdAt: new Date().toISOString(),
120
+ endpoint,
121
+ project: config.project || {}
122
+ };
123
+ writeFileSync(resolve(projectRoot, PENDING_SPEC_FILE), JSON.stringify(pendingSpec, null, 2), 'utf-8');
124
+ console.log(`\nWrote ${PENDING_SPEC_FILE}. Run /forge-tool in Claude.\n`);
125
+ return;
126
+ }
127
+
128
+ const config = loadConfig();
129
+
130
+ // Check if onboarding is needed, and if so, pass the flag to the TUI
131
+ if (needsOnboarding(config)) {
132
+ config._startOnOnboarding = true;
133
+ }
134
+
135
+ await runTui(config);
136
+ }
137
+
138
+ main().catch((err) => {
139
+ console.error(err);
140
+ process.exit(1);
141
+ });