free-coding-models 0.3.5 → 0.3.6
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/CHANGELOG.md +13 -0
- package/README.md +24 -1
- package/bin/free-coding-models.js +57 -61
- package/package.json +4 -2
- package/src/config.js +332 -37
- package/src/endpoint-installer.js +2 -2
- package/src/favorites.js +31 -10
- package/src/key-handler.js +45 -24
- package/src/testfcm.js +451 -0
package/src/key-handler.js
CHANGED
|
@@ -171,6 +171,7 @@ export function createKeyHandler(ctx) {
|
|
|
171
171
|
saveAsProfile,
|
|
172
172
|
setActiveProfile,
|
|
173
173
|
saveConfig,
|
|
174
|
+
persistApiKeysForProvider,
|
|
174
175
|
getConfiguredInstallableProviders,
|
|
175
176
|
getInstallTargetModes,
|
|
176
177
|
getProviderCatalogModels,
|
|
@@ -405,7 +406,7 @@ export function createKeyHandler(ctx) {
|
|
|
405
406
|
})
|
|
406
407
|
setActiveProfile(state.config, name)
|
|
407
408
|
state.activeProfile = name
|
|
408
|
-
saveConfig(state.config)
|
|
409
|
+
saveConfig(state.config, { replaceProfileNames: [name] })
|
|
409
410
|
}
|
|
410
411
|
state.profileSaveMode = false
|
|
411
412
|
state.profileSaveBuffer = ''
|
|
@@ -985,14 +986,24 @@ const updateRowIdx = providerKeys.length
|
|
|
985
986
|
setTimeout(() => { state.settingsErrorMsg = null }, 3000)
|
|
986
987
|
return
|
|
987
988
|
}
|
|
989
|
+
if (!state.config.apiKeys || typeof state.config.apiKeys !== 'object' || Array.isArray(state.config.apiKeys)) {
|
|
990
|
+
state.config.apiKeys = {}
|
|
991
|
+
}
|
|
988
992
|
if (state.settingsAddKeyMode) {
|
|
989
993
|
// 📖 Add-key mode: append new key (addApiKey handles duplicates/empty)
|
|
990
994
|
addApiKey(state.config, pk, newKey)
|
|
991
995
|
} else {
|
|
992
|
-
// 📖 Edit mode: replace the primary key
|
|
993
|
-
state.config
|
|
996
|
+
// 📖 Edit mode: replace only the primary key and keep any extra rotated keys intact.
|
|
997
|
+
const existingKeys = resolveApiKeys(state.config, pk)
|
|
998
|
+
state.config.apiKeys[pk] = existingKeys.length > 1
|
|
999
|
+
? [newKey, ...existingKeys.slice(1)]
|
|
1000
|
+
: newKey
|
|
1001
|
+
}
|
|
1002
|
+
const saveResult = persistApiKeysForProvider(state.config, pk)
|
|
1003
|
+
if (!saveResult.success) {
|
|
1004
|
+
state.settingsErrorMsg = `⚠️ Failed to persist ${pk} API key: ${saveResult.error || 'Unknown error'}`
|
|
1005
|
+
setTimeout(() => { state.settingsErrorMsg = null }, 4000)
|
|
994
1006
|
}
|
|
995
|
-
saveConfig(state.config)
|
|
996
1007
|
}
|
|
997
1008
|
state.settingsEditMode = false
|
|
998
1009
|
state.settingsAddKeyMode = false
|
|
@@ -1103,7 +1114,7 @@ const updateRowIdx = providerKeys.length
|
|
|
1103
1114
|
if (state.settingsCursor === widthWarningRowIdx) {
|
|
1104
1115
|
if (!state.config.settings) state.config.settings = {}
|
|
1105
1116
|
state.config.settings.disableWidthsWarning = !state.config.settings.disableWidthsWarning
|
|
1106
|
-
saveConfig(state.config)
|
|
1117
|
+
saveConfig(state.config, { replaceProfileNames: state.activeProfile ? [state.activeProfile] : [] })
|
|
1107
1118
|
return
|
|
1108
1119
|
}
|
|
1109
1120
|
|
|
@@ -1156,7 +1167,10 @@ const updateRowIdx = providerKeys.length
|
|
|
1156
1167
|
applyTierFilter()
|
|
1157
1168
|
const visible = state.results.filter(r => !r.hidden)
|
|
1158
1169
|
state.visibleSorted = sortResultsWithPinnedFavorites(visible, state.sortColumn, state.sortDirection)
|
|
1159
|
-
saveConfig(state.config
|
|
1170
|
+
saveConfig(state.config, {
|
|
1171
|
+
replaceApiKeys: true,
|
|
1172
|
+
replaceFavorites: true,
|
|
1173
|
+
})
|
|
1160
1174
|
}
|
|
1161
1175
|
}
|
|
1162
1176
|
return
|
|
@@ -1164,7 +1178,7 @@ const updateRowIdx = providerKeys.length
|
|
|
1164
1178
|
|
|
1165
1179
|
// 📖 Enter edit mode for the selected provider's key
|
|
1166
1180
|
const pk = providerKeys[state.settingsCursor]
|
|
1167
|
-
state.settingsEditBuffer = state.config
|
|
1181
|
+
state.settingsEditBuffer = resolveApiKeys(state.config, pk)[0] ?? ''
|
|
1168
1182
|
state.settingsEditMode = true
|
|
1169
1183
|
return
|
|
1170
1184
|
}
|
|
@@ -1176,7 +1190,7 @@ const updateRowIdx = providerKeys.length
|
|
|
1176
1190
|
if (state.settingsCursor === widthWarningRowIdx) {
|
|
1177
1191
|
if (!state.config.settings) state.config.settings = {}
|
|
1178
1192
|
state.config.settings.disableWidthsWarning = !state.config.settings.disableWidthsWarning
|
|
1179
|
-
saveConfig(state.config)
|
|
1193
|
+
saveConfig(state.config, { replaceProfileNames: state.activeProfile ? [state.activeProfile] : [] })
|
|
1180
1194
|
return
|
|
1181
1195
|
}
|
|
1182
1196
|
// 📖 Profile rows don't respond to Space
|
|
@@ -1187,7 +1201,7 @@ const updateRowIdx = providerKeys.length
|
|
|
1187
1201
|
if (!state.config.providers) state.config.providers = {}
|
|
1188
1202
|
if (!state.config.providers[pk]) state.config.providers[pk] = { enabled: true }
|
|
1189
1203
|
state.config.providers[pk].enabled = !isProviderEnabled(state.config, pk)
|
|
1190
|
-
saveConfig(state.config)
|
|
1204
|
+
saveConfig(state.config, { replaceProfileNames: state.activeProfile ? [state.activeProfile] : [] })
|
|
1191
1205
|
return
|
|
1192
1206
|
}
|
|
1193
1207
|
|
|
@@ -1218,7 +1232,7 @@ const updateRowIdx = providerKeys.length
|
|
|
1218
1232
|
setActiveProfile(state.config, null)
|
|
1219
1233
|
state.activeProfile = null
|
|
1220
1234
|
}
|
|
1221
|
-
saveConfig(state.config)
|
|
1235
|
+
saveConfig(state.config, { removedProfileNames: [profileName] })
|
|
1222
1236
|
// 📖 Re-clamp cursor after deletion (profile list just got shorter)
|
|
1223
1237
|
const newProfiles = listProfiles(state.config)
|
|
1224
1238
|
const newMaxRowIdx = newProfiles.length > 0 ? profileStartIdx + newProfiles.length - 1 : changelogViewRowIdx
|
|
@@ -1285,7 +1299,11 @@ const updateRowIdx = providerKeys.length
|
|
|
1285
1299
|
const pk = providerKeys[state.settingsCursor]
|
|
1286
1300
|
const removed = removeApiKey(state.config, pk) // removes last key; collapses array-of-1 to string
|
|
1287
1301
|
if (removed) {
|
|
1288
|
-
|
|
1302
|
+
const saveResult = persistApiKeysForProvider(state.config, pk)
|
|
1303
|
+
if (!saveResult.success) {
|
|
1304
|
+
state.settingsSyncStatus = { type: 'error', msg: `❌ Failed to save API key changes: ${saveResult.error || 'Unknown error'}` }
|
|
1305
|
+
return
|
|
1306
|
+
}
|
|
1289
1307
|
const remaining = resolveApiKeys(state.config, pk).length
|
|
1290
1308
|
const msg = remaining > 0
|
|
1291
1309
|
? `✅ Removed one key for ${pk} (${remaining} remaining)`
|
|
@@ -1325,7 +1343,7 @@ const updateRowIdx = providerKeys.length
|
|
|
1325
1343
|
}
|
|
1326
1344
|
if (!state.config.settings) state.config.settings = {}
|
|
1327
1345
|
state.config.settings.proxy = { ...proxySettings, preferredPort: parsed }
|
|
1328
|
-
saveConfig(state.config)
|
|
1346
|
+
saveConfig(state.config, { replaceProfileNames: state.activeProfile ? [state.activeProfile] : [] })
|
|
1329
1347
|
state.settingsProxyPortEditMode = false
|
|
1330
1348
|
state.settingsProxyPortBuffer = ''
|
|
1331
1349
|
state.proxyDaemonMessage = { type: 'success', msg: `✅ Preferred port saved: ${parsed === 0 ? 'auto' : parsed}`, ts: Date.now() }
|
|
@@ -1366,7 +1384,7 @@ const updateRowIdx = providerKeys.length
|
|
|
1366
1384
|
if (state.proxyDaemonCursor === ROW_PROXY_ENABLED) {
|
|
1367
1385
|
if (!state.config.settings) state.config.settings = {}
|
|
1368
1386
|
state.config.settings.proxy = { ...proxySettings, enabled: !proxySettings.enabled }
|
|
1369
|
-
saveConfig(state.config)
|
|
1387
|
+
saveConfig(state.config, { replaceProfileNames: state.activeProfile ? [state.activeProfile] : [] })
|
|
1370
1388
|
state.proxyDaemonMessage = { type: 'success', msg: `✅ Proxy mode ${state.config.settings.proxy.enabled ? 'enabled' : 'disabled'}`, ts: Date.now() }
|
|
1371
1389
|
return
|
|
1372
1390
|
}
|
|
@@ -1377,7 +1395,7 @@ const updateRowIdx = providerKeys.length
|
|
|
1377
1395
|
}
|
|
1378
1396
|
if (!state.config.settings) state.config.settings = {}
|
|
1379
1397
|
state.config.settings.proxy = { ...proxySettings, syncToOpenCode: !proxySettings.syncToOpenCode }
|
|
1380
|
-
saveConfig(state.config)
|
|
1398
|
+
saveConfig(state.config, { replaceProfileNames: state.activeProfile ? [state.activeProfile] : [] })
|
|
1381
1399
|
const { getToolMeta } = await import('./tool-metadata.js')
|
|
1382
1400
|
const toolLabel = getToolMeta(currentProxyTool).label
|
|
1383
1401
|
state.proxyDaemonMessage = { type: 'success', msg: `✅ Auto-sync to ${toolLabel} ${state.config.settings.proxy.syncToOpenCode ? 'enabled' : 'disabled'}`, ts: Date.now() }
|
|
@@ -1393,7 +1411,7 @@ const updateRowIdx = providerKeys.length
|
|
|
1393
1411
|
if (state.proxyDaemonCursor === ROW_PROXY_ENABLED) {
|
|
1394
1412
|
if (!state.config.settings) state.config.settings = {}
|
|
1395
1413
|
state.config.settings.proxy = { ...proxySettings, enabled: !proxySettings.enabled }
|
|
1396
|
-
saveConfig(state.config)
|
|
1414
|
+
saveConfig(state.config, { replaceProfileNames: state.activeProfile ? [state.activeProfile] : [] })
|
|
1397
1415
|
state.proxyDaemonMessage = { type: 'success', msg: `✅ Proxy mode ${state.config.settings.proxy.enabled ? 'enabled' : 'disabled'}`, ts: Date.now() }
|
|
1398
1416
|
return
|
|
1399
1417
|
}
|
|
@@ -1406,7 +1424,7 @@ const updateRowIdx = providerKeys.length
|
|
|
1406
1424
|
}
|
|
1407
1425
|
if (!state.config.settings) state.config.settings = {}
|
|
1408
1426
|
state.config.settings.proxy = { ...proxySettings, syncToOpenCode: !proxySettings.syncToOpenCode }
|
|
1409
|
-
saveConfig(state.config)
|
|
1427
|
+
saveConfig(state.config, { replaceProfileNames: state.activeProfile ? [state.activeProfile] : [] })
|
|
1410
1428
|
const { getToolMeta } = await import('./tool-metadata.js')
|
|
1411
1429
|
const toolLabel = getToolMeta(currentProxyTool).label
|
|
1412
1430
|
state.proxyDaemonMessage = { type: 'success', msg: `✅ Auto-sync to ${toolLabel} ${state.config.settings.proxy.syncToOpenCode ? 'enabled' : 'disabled'}`, ts: Date.now() }
|
|
@@ -1456,7 +1474,7 @@ const updateRowIdx = providerKeys.length
|
|
|
1456
1474
|
if (!state.config.settings.proxy.preferredPort || state.config.settings.proxy.preferredPort === 0) {
|
|
1457
1475
|
state.config.settings.proxy.preferredPort = 18045
|
|
1458
1476
|
}
|
|
1459
|
-
saveConfig(state.config)
|
|
1477
|
+
saveConfig(state.config, { replaceProfileNames: state.activeProfile ? [state.activeProfile] : [] })
|
|
1460
1478
|
const result = installDaemon()
|
|
1461
1479
|
if (result.success) {
|
|
1462
1480
|
state.proxyDaemonMessage = { type: 'success', msg: '✅ FCM Proxy V2 background service installed and started!', ts: Date.now() }
|
|
@@ -1470,7 +1488,7 @@ const updateRowIdx = providerKeys.length
|
|
|
1470
1488
|
// 📖 Uninstall daemon
|
|
1471
1489
|
const result = uninstallDaemon()
|
|
1472
1490
|
state.config.settings.proxy.daemonEnabled = false
|
|
1473
|
-
saveConfig(state.config)
|
|
1491
|
+
saveConfig(state.config, { replaceProfileNames: state.activeProfile ? [state.activeProfile] : [] })
|
|
1474
1492
|
if (result.success) {
|
|
1475
1493
|
state.proxyDaemonMessage = { type: 'success', msg: '✅ FCM Proxy V2 background service uninstalled.', ts: Date.now() }
|
|
1476
1494
|
state.daemonStatus = 'not-installed'
|
|
@@ -1629,7 +1647,7 @@ const updateRowIdx = providerKeys.length
|
|
|
1629
1647
|
})
|
|
1630
1648
|
setActiveProfile(state.config, 'default')
|
|
1631
1649
|
state.activeProfile = 'default'
|
|
1632
|
-
saveConfig(state.config)
|
|
1650
|
+
saveConfig(state.config, { replaceProfileNames: ['default'] })
|
|
1633
1651
|
} else {
|
|
1634
1652
|
// 📖 Cycle to next profile (or back to null = raw config)
|
|
1635
1653
|
const currentIdx = state.activeProfile ? profiles.indexOf(state.activeProfile) : -1
|
|
@@ -1662,7 +1680,10 @@ const updateRowIdx = providerKeys.length
|
|
|
1662
1680
|
state.visibleSorted = sortResultsWithPinnedFavorites(visible, state.sortColumn, state.sortDirection)
|
|
1663
1681
|
state.cursor = 0
|
|
1664
1682
|
state.scrollOffset = 0
|
|
1665
|
-
saveConfig(state.config
|
|
1683
|
+
saveConfig(state.config, {
|
|
1684
|
+
replaceApiKeys: true,
|
|
1685
|
+
replaceFavorites: true,
|
|
1686
|
+
})
|
|
1666
1687
|
}
|
|
1667
1688
|
}
|
|
1668
1689
|
}
|
|
@@ -1693,7 +1714,7 @@ const updateRowIdx = providerKeys.length
|
|
|
1693
1714
|
profile.settings.sortColumn = state.config.settings.sortColumn
|
|
1694
1715
|
profile.settings.sortAsc = state.config.settings.sortAsc
|
|
1695
1716
|
}
|
|
1696
|
-
saveConfig(state.config)
|
|
1717
|
+
saveConfig(state.config, { replaceProfileNames: state.activeProfile ? [state.activeProfile] : [] })
|
|
1697
1718
|
}
|
|
1698
1719
|
|
|
1699
1720
|
// 📖 Shift+R: reset all UI view settings to defaults (tier, sort, provider) and clear persisted config
|
|
@@ -1717,7 +1738,7 @@ const updateRowIdx = providerKeys.length
|
|
|
1717
1738
|
delete profile.settings.sortAsc
|
|
1718
1739
|
}
|
|
1719
1740
|
}
|
|
1720
|
-
saveConfig(state.config)
|
|
1741
|
+
saveConfig(state.config, { replaceProfileNames: state.activeProfile ? [state.activeProfile] : [] })
|
|
1721
1742
|
applyTierFilter()
|
|
1722
1743
|
const visible = state.results.filter(r => !r.hidden)
|
|
1723
1744
|
state.visibleSorted = sortResultsWithPinnedFavorites(visible, state.sortColumn, state.sortDirection)
|
|
@@ -1824,7 +1845,7 @@ const updateRowIdx = providerKeys.length
|
|
|
1824
1845
|
if (!profile.settings || typeof profile.settings !== 'object') profile.settings = {}
|
|
1825
1846
|
profile.settings.hideUnconfiguredModels = state.hideUnconfiguredModels
|
|
1826
1847
|
}
|
|
1827
|
-
saveConfig(state.config)
|
|
1848
|
+
saveConfig(state.config, { replaceProfileNames: state.activeProfile ? [state.activeProfile] : [] })
|
|
1828
1849
|
applyTierFilter()
|
|
1829
1850
|
const visible = state.results.filter(r => !r.hidden)
|
|
1830
1851
|
state.visibleSorted = sortResultsWithPinnedFavorites(visible, state.sortColumn, state.sortDirection)
|
|
@@ -1891,7 +1912,7 @@ const updateRowIdx = providerKeys.length
|
|
|
1891
1912
|
if (!profile.settings || typeof profile.settings !== 'object') profile.settings = {}
|
|
1892
1913
|
profile.settings.preferredToolMode = state.mode
|
|
1893
1914
|
}
|
|
1894
|
-
saveConfig(state.config)
|
|
1915
|
+
saveConfig(state.config, { replaceProfileNames: state.activeProfile ? [state.activeProfile] : [] })
|
|
1895
1916
|
return
|
|
1896
1917
|
}
|
|
1897
1918
|
|
package/src/testfcm.js
ADDED
|
@@ -0,0 +1,451 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file src/testfcm.js
|
|
3
|
+
* @description Shared helpers for the AI-driven `/testfcm` workflow.
|
|
4
|
+
*
|
|
5
|
+
* @details
|
|
6
|
+
* 📖 These helpers stay side-effect free on purpose so the reporting logic can
|
|
7
|
+
* 📖 be unit-tested without spawning a PTY or touching the user's machine.
|
|
8
|
+
*
|
|
9
|
+
* 📖 The runner in `scripts/testfcm-runner.mjs` handles the live terminal work:
|
|
10
|
+
* 📖 copying config into an isolated HOME, driving the TUI, launching a tool,
|
|
11
|
+
* 📖 sending a prompt, collecting logs, and writing the final Markdown report.
|
|
12
|
+
* 📖 This module focuses on the pieces that should remain stable and reusable:
|
|
13
|
+
* 📖 tool metadata, transcript classification, JSON extraction, and report text.
|
|
14
|
+
*
|
|
15
|
+
* @functions
|
|
16
|
+
* → `normalizeTestfcmToolName` — map aliases like `claude` to canonical FCM tool modes
|
|
17
|
+
* → `resolveTestfcmToolSpec` — return the runner metadata for one tool mode
|
|
18
|
+
* → `hasConfiguredKey` — decide whether a config entry really contains an API key
|
|
19
|
+
* → `createTestfcmRunId` — build a stable timestamp-based run id for artifacts
|
|
20
|
+
* → `extractJsonPayload` — recover JSON mode output even when logs prefix stdout
|
|
21
|
+
* → `detectTranscriptFindings` — map raw tool output to actionable failure findings
|
|
22
|
+
* → `classifyToolTranscript` — classify a run as passed, failed, or inconclusive
|
|
23
|
+
* → `buildFixTasks` — convert findings into concrete follow-up work items
|
|
24
|
+
* → `buildTestfcmReport` — render the final Markdown report written under `task/`
|
|
25
|
+
*
|
|
26
|
+
* @exports TESTFCM_TOOL_SPECS, normalizeTestfcmToolName, resolveTestfcmToolSpec
|
|
27
|
+
* @exports hasConfiguredKey, createTestfcmRunId, extractJsonPayload
|
|
28
|
+
* @exports detectTranscriptFindings, classifyToolTranscript, buildFixTasks
|
|
29
|
+
* @exports buildTestfcmReport
|
|
30
|
+
*/
|
|
31
|
+
|
|
32
|
+
export const TESTFCM_TOOL_SPECS = {
|
|
33
|
+
crush: {
|
|
34
|
+
mode: 'crush',
|
|
35
|
+
label: 'Crush',
|
|
36
|
+
command: 'crush',
|
|
37
|
+
flag: '--crush',
|
|
38
|
+
prefersProxy: true,
|
|
39
|
+
configPaths: ['.config/crush/crush.json'],
|
|
40
|
+
},
|
|
41
|
+
codex: {
|
|
42
|
+
mode: 'codex',
|
|
43
|
+
label: 'Codex CLI',
|
|
44
|
+
command: 'codex',
|
|
45
|
+
flag: '--codex',
|
|
46
|
+
prefersProxy: true,
|
|
47
|
+
configPaths: [],
|
|
48
|
+
},
|
|
49
|
+
'claude-code': {
|
|
50
|
+
mode: 'claude-code',
|
|
51
|
+
label: 'Claude Code',
|
|
52
|
+
command: 'claude',
|
|
53
|
+
flag: '--claude-code',
|
|
54
|
+
prefersProxy: true,
|
|
55
|
+
configPaths: [],
|
|
56
|
+
},
|
|
57
|
+
gemini: {
|
|
58
|
+
mode: 'gemini',
|
|
59
|
+
label: 'Gemini CLI',
|
|
60
|
+
command: 'gemini',
|
|
61
|
+
flag: '--gemini',
|
|
62
|
+
prefersProxy: true,
|
|
63
|
+
configPaths: ['.gemini/settings.json'],
|
|
64
|
+
},
|
|
65
|
+
goose: {
|
|
66
|
+
mode: 'goose',
|
|
67
|
+
label: 'Goose',
|
|
68
|
+
command: 'goose',
|
|
69
|
+
flag: '--goose',
|
|
70
|
+
prefersProxy: true,
|
|
71
|
+
configPaths: ['.config/goose/config.yaml'],
|
|
72
|
+
},
|
|
73
|
+
aider: {
|
|
74
|
+
mode: 'aider',
|
|
75
|
+
label: 'Aider',
|
|
76
|
+
command: 'aider',
|
|
77
|
+
flag: '--aider',
|
|
78
|
+
prefersProxy: false,
|
|
79
|
+
configPaths: ['.aider.conf.yml'],
|
|
80
|
+
},
|
|
81
|
+
qwen: {
|
|
82
|
+
mode: 'qwen',
|
|
83
|
+
label: 'Qwen Code',
|
|
84
|
+
command: 'qwen',
|
|
85
|
+
flag: '--qwen',
|
|
86
|
+
prefersProxy: false,
|
|
87
|
+
configPaths: ['.qwen/settings.json'],
|
|
88
|
+
},
|
|
89
|
+
amp: {
|
|
90
|
+
mode: 'amp',
|
|
91
|
+
label: 'Amp',
|
|
92
|
+
command: 'amp',
|
|
93
|
+
flag: '--amp',
|
|
94
|
+
prefersProxy: false,
|
|
95
|
+
configPaths: ['.config/amp/settings.json'],
|
|
96
|
+
},
|
|
97
|
+
pi: {
|
|
98
|
+
mode: 'pi',
|
|
99
|
+
label: 'Pi',
|
|
100
|
+
command: 'pi',
|
|
101
|
+
flag: '--pi',
|
|
102
|
+
prefersProxy: false,
|
|
103
|
+
configPaths: ['.pi/agent/models.json', '.pi/agent/settings.json'],
|
|
104
|
+
},
|
|
105
|
+
opencode: {
|
|
106
|
+
mode: 'opencode',
|
|
107
|
+
label: 'OpenCode CLI',
|
|
108
|
+
command: 'opencode',
|
|
109
|
+
flag: '--opencode',
|
|
110
|
+
prefersProxy: false,
|
|
111
|
+
configPaths: ['.config/opencode/opencode.json'],
|
|
112
|
+
},
|
|
113
|
+
openhands: {
|
|
114
|
+
mode: 'openhands',
|
|
115
|
+
label: 'OpenHands',
|
|
116
|
+
command: 'openhands',
|
|
117
|
+
flag: '--openhands',
|
|
118
|
+
prefersProxy: false,
|
|
119
|
+
configPaths: [],
|
|
120
|
+
},
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const TESTFCM_TOOL_ALIASES = {
|
|
124
|
+
claude: 'claude-code',
|
|
125
|
+
claudecode: 'claude-code',
|
|
126
|
+
codexcli: 'codex',
|
|
127
|
+
opencodecli: 'opencode',
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
const TRANSCRIPT_FINDING_RULES = [
|
|
131
|
+
{
|
|
132
|
+
id: 'tool_missing',
|
|
133
|
+
title: 'Tool binary missing',
|
|
134
|
+
severity: 'high',
|
|
135
|
+
regex: /could not find "[^"]+" in path|command not found|enoent/i,
|
|
136
|
+
task: 'Install the requested tool binary or pass `--tool-bin-dir` so FCM can launch it during `/testfcm`.',
|
|
137
|
+
},
|
|
138
|
+
{
|
|
139
|
+
id: 'invalid_api_key',
|
|
140
|
+
title: 'Invalid or missing API auth',
|
|
141
|
+
severity: 'high',
|
|
142
|
+
regex: /invalid api|bad api key|incorrect api key|authentication failed|unauthorized|forbidden|missing api key|no api key|anthropic_auth_token|401\b|403\b/i,
|
|
143
|
+
task: 'Validate the provider key used by the selected model, then re-run `/testfcm` and inspect the proxy request log for the failing request.',
|
|
144
|
+
},
|
|
145
|
+
{
|
|
146
|
+
id: 'rate_limited',
|
|
147
|
+
title: 'Provider rate limited',
|
|
148
|
+
severity: 'medium',
|
|
149
|
+
regex: /rate limit|too many requests|quota exceeded|429\b/i,
|
|
150
|
+
task: 'Retry with another configured provider or inspect the retry-after and cooldown handling in the proxy/tool launch flow.',
|
|
151
|
+
},
|
|
152
|
+
{
|
|
153
|
+
id: 'proxy_failure',
|
|
154
|
+
title: 'Proxy startup or routing failure',
|
|
155
|
+
severity: 'high',
|
|
156
|
+
regex: /failed to start proxy|proxy mode .* required|selected model may not exist|routing reload is taking longer than expected|proxy launch is blocked/i,
|
|
157
|
+
task: 'Inspect `request-log.jsonl`, proxy startup messages, and the isolated config to verify the tool can reach the local FCM proxy.',
|
|
158
|
+
},
|
|
159
|
+
{
|
|
160
|
+
id: 'tool_launch_failed',
|
|
161
|
+
title: 'Tool launch failed',
|
|
162
|
+
severity: 'high',
|
|
163
|
+
regex: /failed to launch|failed to start|process exited with code 1|syntaxerror|traceback|fatal:/i,
|
|
164
|
+
task: 'Inspect the tool transcript and generated tool config under the isolated HOME to find the exact launcher failure.',
|
|
165
|
+
},
|
|
166
|
+
]
|
|
167
|
+
|
|
168
|
+
const SUCCESS_PATTERNS = [
|
|
169
|
+
/hello[,! ]/i,
|
|
170
|
+
/how can i help/i,
|
|
171
|
+
/how may i help/i,
|
|
172
|
+
/how can i assist/i,
|
|
173
|
+
/ready to help/i,
|
|
174
|
+
]
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* 📖 Normalize a user/tool alias to the canonical FCM tool mode.
|
|
178
|
+
*
|
|
179
|
+
* @param {string | null | undefined} value
|
|
180
|
+
* @returns {string | null}
|
|
181
|
+
*/
|
|
182
|
+
export function normalizeTestfcmToolName(value) {
|
|
183
|
+
if (typeof value !== 'string' || value.trim().length === 0) return null
|
|
184
|
+
const normalized = value.trim().toLowerCase()
|
|
185
|
+
return TESTFCM_TOOL_ALIASES[normalized] || normalized
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* 📖 Resolve one `/testfcm` tool spec from user input.
|
|
190
|
+
*
|
|
191
|
+
* @param {string | null | undefined} value
|
|
192
|
+
* @returns {typeof TESTFCM_TOOL_SPECS[keyof typeof TESTFCM_TOOL_SPECS] | null}
|
|
193
|
+
*/
|
|
194
|
+
export function resolveTestfcmToolSpec(value) {
|
|
195
|
+
const normalized = normalizeTestfcmToolName(value)
|
|
196
|
+
if (!normalized) return null
|
|
197
|
+
return TESTFCM_TOOL_SPECS[normalized] || null
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* 📖 Treat both string and multi-key array config entries as "configured" when at
|
|
202
|
+
* 📖 least one non-empty key is present.
|
|
203
|
+
*
|
|
204
|
+
* @param {unknown} value
|
|
205
|
+
* @returns {boolean}
|
|
206
|
+
*/
|
|
207
|
+
export function hasConfiguredKey(value) {
|
|
208
|
+
if (typeof value === 'string') return value.trim().length > 0
|
|
209
|
+
if (Array.isArray(value)) return value.some((entry) => typeof entry === 'string' && entry.trim().length > 0)
|
|
210
|
+
return false
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* 📖 Build an artifact-friendly run id such as `20260316-184512`.
|
|
215
|
+
*
|
|
216
|
+
* @param {Date} [date]
|
|
217
|
+
* @returns {string}
|
|
218
|
+
*/
|
|
219
|
+
export function createTestfcmRunId(date = new Date()) {
|
|
220
|
+
const iso = date.toISOString()
|
|
221
|
+
return iso
|
|
222
|
+
.replace(/\.\d{3}Z$/, '')
|
|
223
|
+
.replace(/:/g, '')
|
|
224
|
+
.replace(/-/g, '')
|
|
225
|
+
.replace('T', '-')
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/**
|
|
229
|
+
* 📖 Extract the first valid JSON array payload from mixed stdout text.
|
|
230
|
+
*
|
|
231
|
+
* @param {string} text
|
|
232
|
+
* @returns {Array<object> | null}
|
|
233
|
+
*/
|
|
234
|
+
export function extractJsonPayload(text) {
|
|
235
|
+
const source = String(text || '')
|
|
236
|
+
let offset = source.indexOf('[')
|
|
237
|
+
while (offset !== -1) {
|
|
238
|
+
const candidate = source.slice(offset).trim()
|
|
239
|
+
try {
|
|
240
|
+
const parsed = JSON.parse(candidate)
|
|
241
|
+
return Array.isArray(parsed) ? parsed : null
|
|
242
|
+
} catch {
|
|
243
|
+
offset = source.indexOf('[', offset + 1)
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
return null
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/**
|
|
250
|
+
* 📖 Detect known failure patterns in the raw tool transcript.
|
|
251
|
+
*
|
|
252
|
+
* @param {string} output
|
|
253
|
+
* @returns {Array<{ id: string, title: string, severity: string, task: string, excerpt: string }>}
|
|
254
|
+
*/
|
|
255
|
+
export function detectTranscriptFindings(output) {
|
|
256
|
+
const transcript = String(output || '')
|
|
257
|
+
const findings = []
|
|
258
|
+
|
|
259
|
+
for (const rule of TRANSCRIPT_FINDING_RULES) {
|
|
260
|
+
const match = transcript.match(rule.regex)
|
|
261
|
+
if (!match) continue
|
|
262
|
+
|
|
263
|
+
findings.push({
|
|
264
|
+
id: rule.id,
|
|
265
|
+
title: rule.title,
|
|
266
|
+
severity: rule.severity,
|
|
267
|
+
task: rule.task,
|
|
268
|
+
excerpt: match[0],
|
|
269
|
+
})
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
return findings
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
/**
|
|
276
|
+
* 📖 Decide whether the tool transcript proves success, proves failure, or stays
|
|
277
|
+
* 📖 too ambiguous to trust.
|
|
278
|
+
*
|
|
279
|
+
* @param {string} output
|
|
280
|
+
* @returns {{ status: 'passed' | 'failed' | 'inconclusive', findings: Array<{ id: string, title: string, severity: string, task: string, excerpt: string }>, matchedSuccess: string | null }}
|
|
281
|
+
*/
|
|
282
|
+
export function classifyToolTranscript(output) {
|
|
283
|
+
const transcript = String(output || '')
|
|
284
|
+
const matchedSuccess = SUCCESS_PATTERNS.find((pattern) => pattern.test(transcript))
|
|
285
|
+
|
|
286
|
+
if (matchedSuccess) {
|
|
287
|
+
return {
|
|
288
|
+
status: 'passed',
|
|
289
|
+
findings: [],
|
|
290
|
+
matchedSuccess: matchedSuccess.source,
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
const findings = detectTranscriptFindings(transcript)
|
|
295
|
+
if (findings.length > 0) {
|
|
296
|
+
return {
|
|
297
|
+
status: 'failed',
|
|
298
|
+
findings,
|
|
299
|
+
matchedSuccess: null,
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
return {
|
|
304
|
+
status: 'inconclusive',
|
|
305
|
+
findings: [],
|
|
306
|
+
matchedSuccess: null,
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
/**
|
|
311
|
+
* 📖 Collapse findings into unique human-readable follow-up tasks.
|
|
312
|
+
*
|
|
313
|
+
* @param {Array<{ task: string }>} findings
|
|
314
|
+
* @returns {string[]}
|
|
315
|
+
*/
|
|
316
|
+
export function buildFixTasks(findings) {
|
|
317
|
+
const tasks = new Set()
|
|
318
|
+
for (const finding of findings) {
|
|
319
|
+
if (typeof finding?.task === 'string' && finding.task.trim().length > 0) {
|
|
320
|
+
tasks.add(finding.task.trim())
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
return [...tasks]
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
/**
|
|
327
|
+
* 📖 Render the final Markdown report saved under `task/reports/`.
|
|
328
|
+
*
|
|
329
|
+
* @param {{
|
|
330
|
+
* runId: string,
|
|
331
|
+
* status: 'passed' | 'failed' | 'blocked',
|
|
332
|
+
* startedAt: string,
|
|
333
|
+
* finishedAt: string,
|
|
334
|
+
* toolLabel: string,
|
|
335
|
+
* toolMode: string,
|
|
336
|
+
* prompt: string,
|
|
337
|
+
* configuredProviders: string[],
|
|
338
|
+
* toolBinaryPath: string | null,
|
|
339
|
+
* isolatedHome: string,
|
|
340
|
+
* preflightSummary: string,
|
|
341
|
+
* findings: Array<{ id: string, title: string, severity: string, excerpt: string }>,
|
|
342
|
+
* tasks: string[],
|
|
343
|
+
* evidenceFiles: string[],
|
|
344
|
+
* requestLogSummary: string[],
|
|
345
|
+
* notes: string[],
|
|
346
|
+
* transcriptExcerpt: string
|
|
347
|
+
* }} input
|
|
348
|
+
* @returns {string}
|
|
349
|
+
*/
|
|
350
|
+
export function buildTestfcmReport(input) {
|
|
351
|
+
const lines = []
|
|
352
|
+
const findings = Array.isArray(input.findings) ? input.findings : []
|
|
353
|
+
const tasks = Array.isArray(input.tasks) ? input.tasks : []
|
|
354
|
+
const evidenceFiles = Array.isArray(input.evidenceFiles) ? input.evidenceFiles : []
|
|
355
|
+
const requestLogSummary = Array.isArray(input.requestLogSummary) ? input.requestLogSummary : []
|
|
356
|
+
const notes = Array.isArray(input.notes) ? input.notes : []
|
|
357
|
+
const configuredProviders = Array.isArray(input.configuredProviders) ? input.configuredProviders : []
|
|
358
|
+
const transcriptExcerpt = String(input.transcriptExcerpt || '').trim()
|
|
359
|
+
|
|
360
|
+
lines.push(`# /testfcm Report - ${input.runId}`)
|
|
361
|
+
lines.push('')
|
|
362
|
+
lines.push(`- Status: **${input.status.toUpperCase()}**`)
|
|
363
|
+
lines.push(`- Started: ${input.startedAt}`)
|
|
364
|
+
lines.push(`- Finished: ${input.finishedAt}`)
|
|
365
|
+
lines.push(`- Tool: ${input.toolLabel} (${input.toolMode})`)
|
|
366
|
+
lines.push(`- Prompt sent: \`${input.prompt}\``)
|
|
367
|
+
lines.push(`- Configured providers in isolated run: ${configuredProviders.length > 0 ? configuredProviders.join(', ') : '(none)'}`)
|
|
368
|
+
lines.push(`- Tool binary: ${input.toolBinaryPath || '(not found on PATH)'}`)
|
|
369
|
+
lines.push(`- Isolated HOME: \`${input.isolatedHome}\``)
|
|
370
|
+
lines.push('')
|
|
371
|
+
lines.push('## Summary')
|
|
372
|
+
lines.push('')
|
|
373
|
+
lines.push(input.preflightSummary)
|
|
374
|
+
lines.push('')
|
|
375
|
+
|
|
376
|
+
if (findings.length > 0) {
|
|
377
|
+
lines.push('## Bugs Found')
|
|
378
|
+
lines.push('')
|
|
379
|
+
for (const finding of findings) {
|
|
380
|
+
lines.push(`- [${finding.severity}] ${finding.title} - evidence: \`${finding.excerpt}\``)
|
|
381
|
+
}
|
|
382
|
+
lines.push('')
|
|
383
|
+
} else {
|
|
384
|
+
lines.push('## Bugs Found')
|
|
385
|
+
lines.push('')
|
|
386
|
+
lines.push('- No blocking bug pattern matched the captured transcript in this run.')
|
|
387
|
+
lines.push('')
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
lines.push('## Tasks To Resolve')
|
|
391
|
+
lines.push('')
|
|
392
|
+
if (tasks.length > 0) {
|
|
393
|
+
for (const task of tasks) {
|
|
394
|
+
lines.push(`- ${task}`)
|
|
395
|
+
}
|
|
396
|
+
} else {
|
|
397
|
+
lines.push('- No follow-up task was generated from the captured evidence.')
|
|
398
|
+
}
|
|
399
|
+
lines.push('')
|
|
400
|
+
|
|
401
|
+
lines.push('## Evidence')
|
|
402
|
+
lines.push('')
|
|
403
|
+
if (evidenceFiles.length > 0) {
|
|
404
|
+
for (const file of evidenceFiles) {
|
|
405
|
+
lines.push(`- ${file}`)
|
|
406
|
+
}
|
|
407
|
+
} else {
|
|
408
|
+
lines.push('- No artifact file was captured.')
|
|
409
|
+
}
|
|
410
|
+
lines.push('')
|
|
411
|
+
|
|
412
|
+
lines.push('## Request Log Summary')
|
|
413
|
+
lines.push('')
|
|
414
|
+
if (requestLogSummary.length > 0) {
|
|
415
|
+
for (const entry of requestLogSummary) {
|
|
416
|
+
lines.push(`- ${entry}`)
|
|
417
|
+
}
|
|
418
|
+
} else {
|
|
419
|
+
lines.push('- No proxy request log entry was captured for this run.')
|
|
420
|
+
}
|
|
421
|
+
lines.push('')
|
|
422
|
+
|
|
423
|
+
lines.push('## Notes')
|
|
424
|
+
lines.push('')
|
|
425
|
+
if (notes.length > 0) {
|
|
426
|
+
for (const note of notes) {
|
|
427
|
+
lines.push(`- ${note}`)
|
|
428
|
+
}
|
|
429
|
+
} else {
|
|
430
|
+
lines.push('- No extra notes.')
|
|
431
|
+
}
|
|
432
|
+
lines.push('')
|
|
433
|
+
|
|
434
|
+
lines.push('## Transcript Excerpt')
|
|
435
|
+
lines.push('')
|
|
436
|
+
if (transcriptExcerpt) {
|
|
437
|
+
lines.push('```text')
|
|
438
|
+
lines.push(transcriptExcerpt)
|
|
439
|
+
lines.push('```')
|
|
440
|
+
} else {
|
|
441
|
+
lines.push('```text')
|
|
442
|
+
lines.push('(empty transcript excerpt)')
|
|
443
|
+
lines.push('```')
|
|
444
|
+
}
|
|
445
|
+
lines.push('')
|
|
446
|
+
lines.push('## Next Step')
|
|
447
|
+
lines.push('')
|
|
448
|
+
lines.push('Ask the AI to read this report, summarize the blockers, and propose or apply the fixes.')
|
|
449
|
+
|
|
450
|
+
return lines.join('\n')
|
|
451
|
+
}
|