@capagents/forgeqe 0.0.0-stage → 1.0.1

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 (220) hide show
  1. package/HOW_TO_USE.md +1639 -0
  2. package/README.md +338 -2
  3. package/bin/forgeqe.d.ts +3 -0
  4. package/bin/forgeqe.js +188 -0
  5. package/docs/ARCHITECTURE.md +408 -0
  6. package/docs/security-data-flow.html +1870 -0
  7. package/docs/user-guide.html +2094 -0
  8. package/evals/domain/access-role-change.yaml +37 -0
  9. package/evals/domain/banking-payment-transfer.yaml +41 -0
  10. package/evals/domain/gdpr-account-erasure.yaml +30 -0
  11. package/evals/domain/insurance-claim-fnol.yaml +30 -0
  12. package/evals/domain/insurance-policy-renewal.yaml +37 -0
  13. package/evals/domain/lending-loan-application.yaml +36 -0
  14. package/forgeqe-architecture.html +718 -0
  15. package/forgeqe.example.yaml +203 -0
  16. package/knowledge/README.md +18 -0
  17. package/knowledge/business-rules.md +10 -0
  18. package/knowledge/compliance.md +16 -0
  19. package/knowledge/environments.md +5 -0
  20. package/knowledge/glossary.md +5 -0
  21. package/knowledge/product.md +16 -0
  22. package/knowledge/roles.md +9 -0
  23. package/knowledge/test-data.md +7 -0
  24. package/llms.example.json +51 -0
  25. package/package.json +63 -4
  26. package/prompts/README.md +34 -0
  27. package/prompts/api-scenarios.md +21 -0
  28. package/prompts/api-tests.md +39 -0
  29. package/prompts/brain.md +166 -0
  30. package/prompts/browser-capture.md +7 -0
  31. package/prompts/browser-case-report.md +7 -0
  32. package/prompts/browser-checks.md +14 -0
  33. package/prompts/browser-efficiency.md +7 -0
  34. package/prompts/browser-error-recovery.md +15 -0
  35. package/prompts/browser-extra.md +5 -0
  36. package/prompts/browser-goal-verification.md +13 -0
  37. package/prompts/browser-skill-select.md +6 -0
  38. package/prompts/domain-rule.md +13 -0
  39. package/prompts/eval-judge.md +19 -0
  40. package/prompts/explore-charters.md +27 -0
  41. package/prompts/find-tests.md +28 -0
  42. package/prompts/heal.md +40 -0
  43. package/prompts/pr-review.md +29 -0
  44. package/prompts/skill-rule.md +6 -0
  45. package/prompts/test-agent.md +34 -0
  46. package/prompts/test-reviewer.md +30 -0
  47. package/prompts/test-rules.md +28 -0
  48. package/py/forgeqe_engine/__init__.py +3 -0
  49. package/py/forgeqe_engine/__main__.py +3 -0
  50. package/py/forgeqe_engine/brand.py +453 -0
  51. package/py/forgeqe_engine/brand_assets.py +6 -0
  52. package/py/forgeqe_engine/browser.py +196 -0
  53. package/py/forgeqe_engine/capture.py +538 -0
  54. package/py/forgeqe_engine/checks.py +98 -0
  55. package/py/forgeqe_engine/cli.py +188 -0
  56. package/py/forgeqe_engine/diagnostics.py +609 -0
  57. package/py/forgeqe_engine/llm.py +195 -0
  58. package/py/forgeqe_engine/logins.py +251 -0
  59. package/py/forgeqe_engine/loops.py +224 -0
  60. package/py/forgeqe_engine/memory.py +1004 -0
  61. package/py/forgeqe_engine/network.py +244 -0
  62. package/py/forgeqe_engine/prompts.py +20 -0
  63. package/py/forgeqe_engine/replay.py +240 -0
  64. package/py/forgeqe_engine/report.py +173 -0
  65. package/py/forgeqe_engine/service.py +1335 -0
  66. package/py/forgeqe_engine/settings.py +93 -0
  67. package/py/forgeqe_engine/skills.py +88 -0
  68. package/py/forgeqe_engine/summary.py +239 -0
  69. package/py/forgeqe_engine/urls.py +81 -0
  70. package/py/forgeqe_engine/workflow_yaml.py +689 -0
  71. package/skills/dotnet-nunit-playwright/SKILL.md +34 -0
  72. package/skills/dynamics-365/SKILL.md +21 -0
  73. package/skills/playwright-typescript/SKILL.md +35 -0
  74. package/skills/writing-skills/SKILL.md +30 -0
  75. package/src/api/plan.ts +246 -0
  76. package/src/api/run.ts +204 -0
  77. package/src/api/scenario.ts +502 -0
  78. package/src/api/schema.ts +170 -0
  79. package/src/api/spec.ts +624 -0
  80. package/src/api/store.ts +232 -0
  81. package/src/api/traffic.ts +281 -0
  82. package/src/api/types.ts +163 -0
  83. package/src/api/write.ts +272 -0
  84. package/src/auth/signin.ts +494 -0
  85. package/src/auth/store.ts +170 -0
  86. package/src/brain/index.ts +189 -0
  87. package/src/brain/prompt.ts +19 -0
  88. package/src/capabilities/docs.ts +101 -0
  89. package/src/capabilities/guide.ts +455 -0
  90. package/src/capabilities/registry.ts +343 -0
  91. package/src/capabilities/render.ts +106 -0
  92. package/src/capabilities/status.ts +137 -0
  93. package/src/card.ts +11 -0
  94. package/src/cases/ado.ts +325 -0
  95. package/src/cases/api.ts +117 -0
  96. package/src/cases/bugs.ts +223 -0
  97. package/src/cases/confluence.ts +111 -0
  98. package/src/cases/context.ts +223 -0
  99. package/src/cases/dashboards.ts +137 -0
  100. package/src/cases/failure.ts +97 -0
  101. package/src/cases/files.ts +295 -0
  102. package/src/cases/goal.ts +53 -0
  103. package/src/cases/http.ts +111 -0
  104. package/src/cases/index.ts +131 -0
  105. package/src/cases/jira.ts +122 -0
  106. package/src/cases/qtest.ts +108 -0
  107. package/src/cases/quality.ts +190 -0
  108. package/src/cases/rag.ts +100 -0
  109. package/src/cases/readiness.ts +284 -0
  110. package/src/cases/save.ts +149 -0
  111. package/src/cases/sprints.ts +148 -0
  112. package/src/cases/testrail.ts +176 -0
  113. package/src/cases/types.ts +107 -0
  114. package/src/cases/wiki.ts +105 -0
  115. package/src/cases/xray.ts +206 -0
  116. package/src/cases/zephyr.ts +153 -0
  117. package/src/childEnv.ts +86 -0
  118. package/src/cli/args.ts +114 -0
  119. package/src/cli/help.ts +167 -0
  120. package/src/cli/index.ts +1142 -0
  121. package/src/cli/terminal.ts +187 -0
  122. package/src/code/areas.ts +337 -0
  123. package/src/code/attention.ts +214 -0
  124. package/src/code/find.ts +540 -0
  125. package/src/code/handoff.ts +541 -0
  126. package/src/code/history.ts +170 -0
  127. package/src/code/impact.ts +231 -0
  128. package/src/code/index.ts +34 -0
  129. package/src/code/livebrowser.ts +67 -0
  130. package/src/code/loop.ts +603 -0
  131. package/src/code/notes.ts +75 -0
  132. package/src/code/profile.ts +226 -0
  133. package/src/code/prompt.ts +303 -0
  134. package/src/code/prreview.ts +649 -0
  135. package/src/code/prtests.ts +103 -0
  136. package/src/code/replay.ts +772 -0
  137. package/src/code/review.ts +202 -0
  138. package/src/code/runtime.ts +781 -0
  139. package/src/code/slices.ts +159 -0
  140. package/src/code/testrun.ts +187 -0
  141. package/src/code/traceability.ts +100 -0
  142. package/src/code/worktree.ts +153 -0
  143. package/src/commands.ts +101 -0
  144. package/src/config.ts +970 -0
  145. package/src/detail.ts +65 -0
  146. package/src/domain.ts +19 -0
  147. package/src/engine/bridge.ts +359 -0
  148. package/src/engine/mock.ts +102 -0
  149. package/src/engine/setup.ts +298 -0
  150. package/src/entra.ts +154 -0
  151. package/src/env.ts +24 -0
  152. package/src/evals/domain.ts +338 -0
  153. package/src/explore/charters.ts +151 -0
  154. package/src/explore/run.ts +280 -0
  155. package/src/explore/story.ts +106 -0
  156. package/src/guard.ts +51 -0
  157. package/src/heal/loop.ts +357 -0
  158. package/src/heal/prompt.ts +143 -0
  159. package/src/heal/report.ts +95 -0
  160. package/src/index.ts +12 -0
  161. package/src/init.ts +102 -0
  162. package/src/knowledge.ts +114 -0
  163. package/src/legacyEnv.ts +9 -0
  164. package/src/llm.ts +61 -0
  165. package/src/locale.ts +25 -0
  166. package/src/logins.ts +148 -0
  167. package/src/managed.ts +101 -0
  168. package/src/mcp/apitools.ts +335 -0
  169. package/src/mcp/captools.ts +25 -0
  170. package/src/mcp/casetools.ts +223 -0
  171. package/src/mcp/commands.ts +421 -0
  172. package/src/mcp/exploretools.ts +129 -0
  173. package/src/mcp/external.ts +172 -0
  174. package/src/mcp/git.ts +78 -0
  175. package/src/mcp/notifytools.ts +49 -0
  176. package/src/mcp/prtools.ts +261 -0
  177. package/src/mcp/qualitytools.ts +154 -0
  178. package/src/mcp/repotests.ts +286 -0
  179. package/src/mcp/server.ts +234 -0
  180. package/src/mcp/tools.ts +810 -0
  181. package/src/memory.ts +250 -0
  182. package/src/money.ts +39 -0
  183. package/src/monitor/alerts.ts +94 -0
  184. package/src/monitor/background.ts +68 -0
  185. package/src/monitor/run.ts +263 -0
  186. package/src/monitor/state.ts +105 -0
  187. package/src/notify/events.ts +144 -0
  188. package/src/notify/notice.ts +177 -0
  189. package/src/prices.ts +175 -0
  190. package/src/print.ts +54 -0
  191. package/src/privacy.ts +35 -0
  192. package/src/prompts.ts +165 -0
  193. package/src/quality/budgets.ts +249 -0
  194. package/src/quality/history.ts +149 -0
  195. package/src/quality/visual.ts +201 -0
  196. package/src/report/data.ts +475 -0
  197. package/src/report/html.ts +106 -0
  198. package/src/report/template.html +84 -0
  199. package/src/run.ts +243 -0
  200. package/src/sessionLog.ts +216 -0
  201. package/src/sessionLogins.ts +94 -0
  202. package/src/skills/registry.ts +217 -0
  203. package/src/skills/scaffold.ts +65 -0
  204. package/src/skills/select.ts +37 -0
  205. package/src/skills/view.ts +44 -0
  206. package/src/suite/report.ts +135 -0
  207. package/src/suite/run.ts +303 -0
  208. package/src/summary.ts +188 -0
  209. package/src/tools.ts +130 -0
  210. package/src/tui/App.tsx +983 -0
  211. package/src/tui/clipboard.ts +36 -0
  212. package/src/tui/components.tsx +263 -0
  213. package/src/tui/dialogs.ts +65 -0
  214. package/src/tui/main.tsx +20 -0
  215. package/src/tui/text.ts +28 -0
  216. package/src/tui/theme.ts +104 -0
  217. package/src/tui/transcript.tsx +344 -0
  218. package/src/types.ts +146 -0
  219. package/src/update.ts +311 -0
  220. package/src/usage.ts +158 -0
@@ -0,0 +1,1142 @@
1
+ import { spawn } from "node:child_process"
2
+ import { copyFileSync, existsSync, mkdirSync, readFileSync, renameSync } from "node:fs"
3
+ import path from "node:path"
4
+
5
+ import { projectDir } from "../tools.ts"
6
+
7
+ import { NO_MODEL_SET_UP } from "../llm.ts"
8
+ import { brainUnavailable, openBrain, sessionSetup, type Brain } from "../brain/index.ts"
9
+ import { goalFor, loadCases } from "../cases/index.ts"
10
+ import { codeSkipReason, latestRunDir, runCodeAgent } from "../code/index.ts"
11
+ import { detailFrom } from "../detail.ts"
12
+ import { ensureOpencode, INSTALL_HINT } from "../code/runtime.ts"
13
+ import { loadConfig, parseDuration, replayMode, selectProfile, userConfigDir, type AppConfig } from "../config.ts"
14
+ import { runHeal } from "../heal/loop.ts"
15
+ import { formatHeal } from "../heal/report.ts"
16
+ import { backgroundMonitors, stopBackgroundMonitors } from "../monitor/background.ts"
17
+ import { formatRound, runMonitor, sendTestAlert } from "../monitor/run.ts"
18
+ import { ignoreReports, initWorkspace } from "../init.ts"
19
+ import { clearLogins, describeLogins, interactiveLogin } from "../logins.ts"
20
+ import { clearMemory, memoryFilesFor, memoryFlows, memoryOverview, memoryShow } from "../memory.ts"
21
+ import { formatEvent } from "../print.ts"
22
+ import { bundleLogs, logEvent, recentLogs, startSessionLog } from "../sessionLog.ts"
23
+ import { executeRun, runDirectory } from "../run.ts"
24
+ import { findSkill, loadSkills, SKILL_AGENTS, type SkillAgent } from "../skills/registry.ts"
25
+ import { ignoredNote, newSkill, setSkillDisabled } from "../skills/scaffold.ts"
26
+ import { skillDetail, skillsOverview } from "../skills/view.ts"
27
+ import { buildCodeSummary, buildSummary, formatCodeSummary, formatSummary } from "../summary.ts"
28
+ import { engineDoctor, ensureEngine } from "../engine/setup.ts"
29
+ import { externalMcp } from "../mcp/external.ts"
30
+ import { createMcpServer, serveStdio } from "../mcp/server.ts"
31
+ import { TOOLS, environmentNotes, type ToolContext } from "../mcp/tools.ts"
32
+ import { formatSuite } from "../suite/report.ts"
33
+ import { runSuite, suiteExitCode } from "../suite/run.ts"
34
+ import { packageVersion } from "../tui/theme.ts"
35
+ import { update } from "../update.ts"
36
+ import { meterSnapshot, requestLine } from "../usage.ts"
37
+ import { boolFlag, configOpts, maxStepsFlag, memoryFlags, parseArgs, repoFlag, skillFlags, strFlag, type Flags } from "./args.ts"
38
+ import { HELP_TEXT } from "./help.ts"
39
+ import { suiteEventLine, terminalAsk, terminalInput, terminalWait, toolPrinter, writeJobSummary } from "./terminal.ts"
40
+ import { applySavedCredentials } from "../auth/store.ts"
41
+ import { ensurePrices } from "../prices.ts"
42
+ import { authStatus, signIn, signOut } from "../auth/signin.ts"
43
+ import { resultsCard, runSelection, selectionCard } from "../code/find.ts"
44
+ import { listSets as listApiSets } from "../api/store.ts"
45
+ import { builtinPrompt, lineDiff, listPrompts, prompt, promptDirs, promptFile, promptWarnings } from "../prompts.ts"
46
+ import { resultsMarkdown, selectPrTests } from "../code/prtests.ts"
47
+ import { postPrComment, resolvePr } from "../code/prreview.ts"
48
+ import { privateEnvNames } from "../childEnv.ts"
49
+ import { cardText } from "../card.ts"
50
+ import { featuresText } from "../capabilities/render.ts"
51
+ import { DOMAIN_CASES, gate, loadDomainCases, reportMarkdown, runDomainEval, saveReport, VARIANTS, type Variant } from "../evals/domain.ts"
52
+ import { evalNotice, notifiedLine, notify, notifySuite } from "../notify/events.ts"
53
+ import { sendNotice, type Notice } from "../notify/notice.ts"
54
+
55
+ const AGENT_COMMANDS = new Set(["start", "run", "code", "suite", "heal", "monitor", "explore"])
56
+ const LOGGED_COMMANDS = new Set([...AGENT_COMMANDS, "mcp", "login", "setup"])
57
+
58
+ /** How much of the agents' work to print: brief unless --verbose. */
59
+ const detailOf = (flags: Flags) => detailFrom(boolFlag(flags, "verbose"))
60
+
61
+ function debugRequested(flags: Flags): boolean {
62
+ return boolFlag(flags, "debug") || /^(1|true|yes|on)$/i.test(process.env.FORGEQE_DEBUG || "")
63
+ }
64
+
65
+ /** Every command that runs something writes a session log; a broken config is reported by the command itself. */
66
+ function openSessionLog(command: string, flags: Flags, argv: string[]): void {
67
+ try {
68
+ const file = startSessionLog(loadConfig(configOpts(flags)), { command, debug: debugRequested(flags), argv })
69
+ if (file && debugRequested(flags) && command !== "start") console.error(`Session log: ${file}`)
70
+ } catch {
71
+ // no config yet
72
+ }
73
+ }
74
+
75
+ /** llms.json prices that differ from the list price, for the profiles this command uses. */
76
+ function priceWarnings(flags: Flags): string[] {
77
+ try {
78
+ const cfg = loadConfig(configOpts(flags))
79
+ const used = [cfg.llm, cfg.code.reviewProfile ? cfg.profiles[cfg.code.reviewProfile] : undefined]
80
+ return [...new Set(used.map((p) => p?.priceWarning).filter((w): w is string => Boolean(w)))]
81
+ } catch {
82
+ return []
83
+ }
84
+ }
85
+
86
+ function configuredOpencode(flags: Flags): string | undefined {
87
+ try {
88
+ return loadConfig(configOpts(flags)).code.opencode
89
+ } catch {
90
+ return undefined
91
+ }
92
+ }
93
+
94
+ /** Keeps the default reports folder beside forgeqe.yaml out of git, for projects set up before init did it. */
95
+ function ignoreDefaultReports(flags: Flags): void {
96
+ try {
97
+ const cfg = loadConfig(configOpts(flags))
98
+ if (!cfg.configPath) return
99
+ const dir = path.dirname(cfg.configPath)
100
+ if (dir === userConfigDir() || path.relative(path.join(projectDir(dir), "reports"), cfg.export.outDir).startsWith("..")) return
101
+ const note = ignoreReports(dir)
102
+ if (note) console.error(note)
103
+ } catch {
104
+ // a broken config is reported by the command itself
105
+ }
106
+ }
107
+
108
+ /**
109
+ * Run a command that uses models, then print what it used and cost in total, by role (ForgeQE's agent, the browser
110
+ * agent, Scribe, the reviewer). Each job's own summary has its Usage line too.
111
+ */
112
+ async function withUsageLine(run: () => Promise<number>): Promise<number> {
113
+ const before = meterSnapshot()
114
+ try {
115
+ return await run()
116
+ } finally {
117
+ const line = requestLine(before, "This command used")
118
+ if (line) console.log(`\n${line}`)
119
+ }
120
+ }
121
+
122
+ export async function main(argv: string[]): Promise<number> {
123
+ const { positionals, flags } = parseArgs(argv)
124
+ const command = positionals[0] || "help"
125
+ if (command === "version" || (command === "help" && (flags.get("version") === true || flags.get("v") === true))) {
126
+ console.log(packageVersion())
127
+ return 0
128
+ }
129
+ if (boolFlag(flags, "help") || command === "help" || command === "--help") {
130
+ console.log(HELP_TEXT)
131
+ return 0
132
+ }
133
+
134
+ // Sign-ins saved with forgeqe auth login, under the names the config reads; the terminal's own variables win.
135
+ await applySavedCredentials().catch(() => [])
136
+ // Models' list prices for costs: waited for only the first time, refreshed daily in the background after that.
137
+ await ensurePrices()
138
+ if (LOGGED_COMMANDS.has(command)) openSessionLog(command, flags, argv)
139
+ if (AGENT_COMMANDS.has(command) && command !== "start") for (const w of [...priceWarnings(flags), ...promptWarnings()]) console.error(`Note: ${w}`)
140
+ try {
141
+ if (AGENT_COMMANDS.has(command)) {
142
+ await ensureOpencode(configuredOpencode(flags), (text) => console.error(text))
143
+ ignoreDefaultReports(flags)
144
+ }
145
+ switch (command) {
146
+ case "init":
147
+ return cmdInit(flags)
148
+ case "setup":
149
+ return await cmdSetup(flags)
150
+ case "models":
151
+ return cmdModels(flags)
152
+ case "docs":
153
+ return cmdDocs(flags)
154
+ case "features":
155
+ // What ForgeQE can do, and whether each capability works with this config and machine.
156
+ console.log(featuresText(loadConfig(configOpts(flags))))
157
+ return 0
158
+ case "start":
159
+ return await cmdStart(flags)
160
+ case "run":
161
+ return await withUsageLine(() => cmdRun(positionals.slice(1), flags))
162
+ case "mcp":
163
+ return await cmdMcp(positionals.slice(1), flags)
164
+ case "memory":
165
+ return cmdMemory(positionals.slice(1), flags)
166
+ case "code":
167
+ return await withUsageLine(() => cmdCode(positionals.slice(1), flags))
168
+ case "cases":
169
+ return await cmdCases(positionals.slice(1), flags)
170
+ case "suite":
171
+ return await withUsageLine(() => cmdSuite(positionals.slice(1), flags))
172
+ case "heal":
173
+ return await withUsageLine(() => cmdHeal(positionals.slice(1), flags))
174
+ case "monitor":
175
+ return await cmdMonitor(positionals.slice(1), flags)
176
+ case "auth":
177
+ return await cmdAuth(positionals.slice(1), flags)
178
+ case "login":
179
+ return await interactiveLogin(loadConfig(configOpts(flags)), positionals[1], strFlag(flags, "url"))
180
+ case "pr-tests":
181
+ return await withUsageLine(() => cmdPrTests(positionals.slice(1), flags))
182
+ case "api":
183
+ return await withUsageLine(() => cmdApi(positionals.slice(1), flags))
184
+ case "explore":
185
+ return await withUsageLine(() => cmdExplore(positionals.slice(1), flags))
186
+ case "prompts":
187
+ return cmdPrompts(positionals.slice(1), flags)
188
+ case "eval":
189
+ return await withUsageLine(() => cmdEval(positionals.slice(1), flags))
190
+ case "notify":
191
+ return await cmdNotify(positionals.slice(1), flags)
192
+ case "visual": {
193
+ // forgeqe visual [run]: the run's pages against their baselines; forgeqe visual approve [run] [--page a,b].
194
+ const cfg = loadConfig(configOpts(flags))
195
+ const approve = positionals[1] === "approve"
196
+ const color = Boolean(process.stdout.isTTY) && !process.env.NO_COLOR
197
+ const ask = approve && process.stdin.isTTY && !boolFlag(flags, "yes") ? terminalAsk(new AbortController().signal) : undefined
198
+ const ctx: ToolContext = { config: cfg, cwd: process.cwd(), session: "once", emit: toolPrinter(color, (line) => console.log(line), detailOf(flags)), ...(ask ? { ask } : {}) }
199
+ const pages = (strFlag(flags, "page") || "").split(",").map((s) => s.trim()).filter(Boolean)
200
+ const run = approve ? positionals[2] : positionals[1]
201
+ const out = (await TOOLS.find((t) => t.name === (approve ? "visual_approve" : "visual_compare"))!.run({ run_dir: run, ...(pages.length ? { pages } : {}) }, ctx)) as { pages?: { over_budget?: boolean }[] }
202
+ return out.pages?.some((p) => p.over_budget) ? 1 : 0
203
+ }
204
+ case "quality": {
205
+ // forgeqe quality [run-dir]: a run's WCAG and Core Web Vitals; forgeqe quality trends [page]: over time.
206
+ const cfg = loadConfig(configOpts(flags))
207
+ const trends = positionals[1] === "trends"
208
+ const color = Boolean(process.stdout.isTTY) && !process.env.NO_COLOR
209
+ const ctx: ToolContext = { config: cfg, cwd: process.cwd(), session: "once", emit: toolPrinter(color, (line) => console.log(line), detailOf(flags)) }
210
+ const out = (await TOOLS.find((t) => t.name === (trends ? "quality_trends" : "quality_report"))!.run(trends ? { page: positionals[2] } : { run_dir: positionals[1] }, ctx)) as { budget_exceeded?: unknown[]; pages?: { slower: unknown[]; newly_failing_rules: unknown[] }[] }
211
+ return out.budget_exceeded?.length || out.pages?.some((p) => p.slower.length || p.newly_failing_rules.length) ? 1 : 0
212
+ }
213
+ case "logs":
214
+ return cmdLogs(positionals.slice(1), flags)
215
+ case "skills":
216
+ return cmdSkills(positionals.slice(1), flags)
217
+ case "update":
218
+ return await update({
219
+ version: strFlag(flags, "version") || positionals[1],
220
+ check: boolFlag(flags, "check"),
221
+ waitMinutes: strFlag(flags, "wait") ? Number(strFlag(flags, "wait")) : undefined,
222
+ })
223
+ case "logins": {
224
+ const cfg = loadConfig(configOpts(flags))
225
+ if (positionals[1] === "clear") console.log(clearLogins(cfg, positionals[2], boolFlag(flags, "all")))
226
+ else console.log(describeLogins(cfg))
227
+ return 0
228
+ }
229
+ default:
230
+ console.error(`Unknown command "${command}".\n`)
231
+ console.log(HELP_TEXT)
232
+ return 1
233
+ }
234
+ } catch (err) {
235
+ logEvent("error", { message: (err as Error).message || String(err), stack: (err as Error).stack })
236
+ console.error((err as Error).message || String(err))
237
+ return 1
238
+ }
239
+ }
240
+
241
+ function cmdLogs(args: string[], flags: Flags): number {
242
+ const cfg = loadConfig(configOpts(flags))
243
+ if (args[0] === "bundle") {
244
+ const out = bundleLogs(cfg, { log: args[1] ? path.resolve(args[1]) : undefined, shots: boolFlag(flags, "shots") })
245
+ console.log(`log ${out.log}`)
246
+ console.log(`runs ${out.runs}`)
247
+ console.log(`archive ${out.archive}`)
248
+ console.log("\nSecrets are replaced in every file. Share the archive to get help with this session.")
249
+ return 0
250
+ }
251
+ const logs = recentLogs(cfg)
252
+ if (!logs.length) {
253
+ console.log("No session logs yet. Each forgeqe session writes one to .forgeqe/logs.")
254
+ return 0
255
+ }
256
+ for (const l of logs) console.log(`${l.modified.toLocaleString()} ${String(Math.ceil(l.size / 1024)).padStart(6)} KB ${l.file}`)
257
+ console.log("\nforgeqe logs bundle archive the newest one with its runs, to share")
258
+ return 0
259
+ }
260
+
261
+ function cmdInit(flags: Flags): number {
262
+ const global = boolFlag(flags, "global")
263
+ const dir = global ? userConfigDir() : path.resolve(strFlag(flags, "dir") || ".")
264
+ const { created, notes } = initWorkspace(dir, {
265
+ force: boolFlag(flags, "force"),
266
+ profile: strFlag(flags, "profile"),
267
+ })
268
+ for (const file of created) console.log(`created ${file}`)
269
+ for (const note of notes) console.log(note)
270
+ if (!created.length) return 0
271
+ console.log("")
272
+ console.log("Next:")
273
+ console.log(` 1. Edit ${path.join(dir, "llms.json")}: a key in an environment variable (api_key_env), or auth: entra to sign in to Azure OpenAI with no key`)
274
+ console.log(" 2. Sign in to your tools and save keys: forgeqe auth login (or put them in a .env file)")
275
+ console.log(` 3. Describe your product in ${path.join(dir, ".forgeqe", "knowledge")} (product, roles, rules, test data, and the controls you must meet in compliance.md)`)
276
+ console.log(` 4. In ${path.join(dir, "forgeqe.yaml")}: your industry and regulations (domain:) and Teams or email channels (notify:); then forgeqe notify test`)
277
+ console.log(` 5. Optional: adjust the agents' prompts in ${path.join(dir, ".forgeqe", "prompts")} and skills in ${path.join(dir, ".forgeqe", "skills")}`)
278
+ console.log(" 6. forgeqe models and forgeqe features, then forgeqe start")
279
+ return 0
280
+ }
281
+
282
+ async function cmdSetup(flags: Flags): Promise<number> {
283
+ const runtime = await ensureOpencode(configuredOpencode(flags), (text) => console.log(text))
284
+ const python = await ensureEngine((text) => console.log(text), { force: boolFlag(flags, "force") })
285
+ const doc = engineDoctor(python)
286
+ if (!doc.browser_use) {
287
+ console.error(`Engine check failed for ${python}: ${doc.error}`)
288
+ console.error("Run forgeqe setup --force to rebuild ForgeQE's engine environment.")
289
+ return 1
290
+ }
291
+ console.log(`python ${python} (${doc.python})`)
292
+ console.log(`engine ready (${doc.browser_use})`)
293
+ console.log(`browser ${doc.executable_path || doc.channel}`)
294
+ console.log(`runtime ${runtime || `not available (${INSTALL_HINT})`}`)
295
+ return 0
296
+ }
297
+
298
+ function cmdModels(flags: Flags): number {
299
+ const cfg = loadConfig(configOpts(flags))
300
+ if (!Object.keys(cfg.profiles).length) {
301
+ console.log(NO_MODEL_SET_UP)
302
+ if (cfg.configPath) console.log(`\nconfig ${cfg.configPath}`)
303
+ return 0
304
+ }
305
+ const width = Math.max(8, ...Object.keys(cfg.profiles).map((n) => n.length))
306
+ console.log(`${"PROFILE".padEnd(width)} PROVIDER MODEL`)
307
+ for (const [name, profile] of Object.entries(cfg.profiles)) {
308
+ const mark = name === cfg.activeProfile ? "*" : " "
309
+ const p = profile.price
310
+ const price = p ? ` ${p.input}/${p.cachedInput ?? "?"}/${p.output} ${p.currency} per M in/cached/out${p.note ? ` (${p.note})` : ""}` : " no price known"
311
+ console.log(`${mark} ${name.padEnd(width)} ${profile.provider.padEnd(8)} ${profile.model}${price}`)
312
+ if (profile.priceWarning) console.log(` ! ${profile.priceWarning}`)
313
+ }
314
+ if (cfg.configPath) console.log(`\nconfig ${cfg.configPath}`)
315
+ return 0
316
+ }
317
+
318
+ const ARCHITECTURE_PAGE = "forgeqe-architecture.html"
319
+
320
+ function openFile(file: string): void {
321
+ const [cmd, args] =
322
+ process.platform === "darwin" ? ["open", [file]] : process.platform === "win32" ? ["cmd", ["/c", "start", "", file]] : ["xdg-open", [file]]
323
+ spawn(cmd as string, args as string[], { stdio: "ignore", detached: true }).unref()
324
+ }
325
+
326
+ function configOrNone(flags: Flags): AppConfig | undefined {
327
+ try {
328
+ return loadConfig(configOpts(flags))
329
+ } catch {
330
+ return undefined
331
+ }
332
+ }
333
+
334
+ /**
335
+ * forgeqe pr-tests [pr] [--base BRANCH] [--agent] [--run] [--post]: the tests a pull request (or the commits since a
336
+ * base branch) can break. Without --agent no model is used. --run runs them (the flag is the consent) and the exit code
337
+ * is the result, for a CI gate: 0 when they pass or none are affected, 1 when one fails, 2 when there is no command.
338
+ */
339
+ async function cmdPrTests(args: string[], flags: Flags): Promise<number> {
340
+ const cfg = loadConfig(configOpts(flags))
341
+ const repo = strFlag(flags, "repo") || process.cwd()
342
+ const out = await selectPrTests({
343
+ config: cfg,
344
+ repo,
345
+ pr: args[0],
346
+ base: strFlag(flags, "base"),
347
+ agent: boolFlag(flags, "agent"),
348
+ onEvent: (e) => e.kind === "info" && console.error(e.text),
349
+ })
350
+ for (const n of out.notes) console.error(n)
351
+ for (const t of out.impact.tests) console.log(`• ${t.file}\n ${t.reasons.join("; ")}`)
352
+ if (out.impact.untested.length) console.log(`\nNo test reaches: ${out.impact.untested.join(", ")}`)
353
+ if (!out.selection) return out.impact.tests.length ? 2 : 0
354
+ console.log(`\n${cardText(selectionCard(out.selection, process.cwd()))}`)
355
+ if (!boolFlag(flags, "run")) return 0
356
+ const run = await runSelection({ selection: out.selection, timeoutSeconds: cfg.code.testTimeoutSeconds, hide: privateEnvNames(cfg), flaky: cfg.flaky })
357
+ console.log(`\n${cardText(resultsCard(run, process.cwd()))}`)
358
+ if (boolFlag(flags, "post") && args[0]) {
359
+ const { root, ref } = await resolvePr(repo, args[0])
360
+ console.log(`Results posted: ${await postPrComment(ref, cfg, root, resultsMarkdown(run, `pull request ${ref.number}`))}`)
361
+ }
362
+ return run.status === "passed" ? 0 : 1
363
+ }
364
+
365
+ /**
366
+ * forgeqe api contracts --spec <link|file> | --run [RUN] · api run [SET] · api tests [SET] · api list: API and
367
+ * contract tests. `api run` exits 1 when a contract fails, for a CI gate; --allow-writes is the consent for calls that
368
+ * change data.
369
+ */
370
+ async function cmdApi(args: string[], flags: Flags): Promise<number> {
371
+ const cfg = loadConfig(configOpts(flags))
372
+ const action = args[0] || "list"
373
+ const color = Boolean(process.stdout.isTTY) && !process.env.NO_COLOR
374
+ const ctx: ToolContext = { config: cfg, cwd: process.cwd(), session: "once", emit: toolPrinter(color, (line) => console.log(line), detailOf(flags)) }
375
+ const tool = (name: string) => TOOLS.find((t) => t.name === name)!
376
+ const csv = (name: string) => (strFlag(flags, name) || "").split(",").map((s) => s.trim()).filter(Boolean)
377
+ switch (action) {
378
+ case "list": {
379
+ const sets = listApiSets(cfg)
380
+ if (!sets.length) console.log("No API contracts yet. Make some: forgeqe api contracts --spec <link or file>, or --run after a browser run.")
381
+ for (const s of sets) console.log(`${s.id} ${s.contracts} contracts${s.scenarios ? `, ${s.scenarios} scenarios` : ""} ${s.name}`)
382
+ return 0
383
+ }
384
+ case "contracts": {
385
+ const spec = strFlag(flags, "spec")
386
+ const run = flags.get("run")
387
+ await tool("api_contracts").run(
388
+ { ...(spec ? { from: "spec", spec } : { from: "run", ...(typeof run === "string" ? { run_dir: run } : args[1] ? { run_dir: args[1] } : {}) }), include: csv("include"), name: strFlag(flags, "name"), base_url: strFlag(flags, "base-url"), third_party: boolFlag(flags, "third-party") },
389
+ ctx,
390
+ )
391
+ return 0
392
+ }
393
+ case "run": {
394
+ const out = (await tool("run_api_contracts").run(
395
+ { set_id: args[1], base_url: strFlag(flags, "base-url"), only: csv("only"), allow_writes: boolFlag(flags, "allow-writes"), include_needs_data: boolFlag(flags, "include-needs-data") },
396
+ ctx,
397
+ )) as { counts: { failed: number; error: number; passed: number } }
398
+ return out.counts.failed || out.counts.error ? 1 : 0
399
+ }
400
+ case "tests": {
401
+ await ensureOpencode(cfg.code.opencode, (text) => console.error(text))
402
+ const scenarios = flags.get("scenarios") === true ? ["all"] : csv("scenarios")
403
+ const out = (await tool("write_api_tests").run({ set_id: args[1], repo: strFlag(flags, "repo"), only: csv("only"), include_writes: boolFlag(flags, "include-writes"), base_url: strFlag(flags, "base-url"), scenarios }, ctx)) as { status: string }
404
+ return out.status === "passed" ? 0 : 1
405
+ }
406
+ case "scenarios": {
407
+ const run = flags.get("run")
408
+ const from = strFlag(flags, "text") ? "text" : strFlag(flags, "cases") ? "cases" : run !== undefined ? "run" : "spec"
409
+ if (from === "text" || from === "cases") await ensureOpencode(cfg.code.opencode, (text) => console.error(text))
410
+ await tool("api_scenarios").run(
411
+ {
412
+ from,
413
+ ...(typeof run === "string" ? { run_dir: run } : {}),
414
+ spec: strFlag(flags, "spec"),
415
+ set_id: args[1],
416
+ cases: csv("cases"),
417
+ text: strFlag(flags, "text"),
418
+ negative: !boolFlag(flags, "no-negative"),
419
+ name: strFlag(flags, "name"),
420
+ base_url: strFlag(flags, "base-url"),
421
+ },
422
+ ctx,
423
+ )
424
+ return 0
425
+ }
426
+ case "run-scenarios": {
427
+ // --allow-writes is the consent for scenarios that change data; without it only read-only scenarios run.
428
+ const out = (await tool("run_api_scenarios").run({ set_id: args[1], only: csv("only"), base_url: strFlag(flags, "base-url"), allow_writes: boolFlag(flags, "allow-writes") }, ctx)) as { counts: { failed: number; error: number } }
429
+ return out.counts.failed || out.counts.error ? 1 : 0
430
+ }
431
+ default:
432
+ throw new Error("Usage: forgeqe api [list | contracts --spec <link|file> | contracts --run [RUN] | run [SET] | tests [SET] [--scenarios] | scenarios --run [RUN] | --spec <link|file> | --cases <refs> | --text <scenario> | run-scenarios [SET]]")
433
+ }
434
+ }
435
+
436
+ /**
437
+ * forgeqe explore <story> [--url URL] [--charters N] [--roles a,b] [--focus TEXT] [--yes] [--file-bugs]: exploratory
438
+ * testing of a story. In a terminal it asks before running the charters and before filing bugs; --yes runs without
439
+ * asking (bugs only with --file-bugs). Exits 1 when a defect is confirmed.
440
+ */
441
+ async function cmdExplore(args: string[], flags: Flags): Promise<number> {
442
+ const story = args.join(" ").trim()
443
+ if (!story) throw new Error('Usage: forgeqe explore <ado:123 | SHOP-12 | link | "story text"> [--url URL] [--charters N] [--roles a,b] [--focus TEXT] [--yes] [--file-bugs]')
444
+ const cfg = loadConfig(configOpts(flags))
445
+ const color = Boolean(process.stdout.isTTY) && !process.env.NO_COLOR
446
+ const controller = new AbortController()
447
+ const onSigint = () => controller.abort()
448
+ process.once("SIGINT", onSigint)
449
+ try {
450
+ const ask = process.stdin.isTTY && !boolFlag(flags, "yes") ? terminalAsk(controller.signal) : undefined
451
+ const ctx: ToolContext = { config: cfg, cwd: process.cwd(), session: "once", signal: controller.signal, headed: boolFlag(flags, "headed"), maxSteps: maxStepsFlag(flags), emit: toolPrinter(color, (line) => console.log(line), detailOf(flags)), ...(ask ? { ask } : {}) }
452
+ const roles = (strFlag(flags, "roles") || "").split(",").map((s) => s.trim()).filter(Boolean)
453
+ const out = (await TOOLS.find((t) => t.name === "explore_story")!.run(
454
+ { story, url: strFlag(flags, "url"), charters: Number(strFlag(flags, "charters")) || undefined, ...(roles.length ? { roles } : {}), focus: strFlag(flags, "focus"), file_bugs: boolFlag(flags, "file-bugs") },
455
+ ctx,
456
+ )) as { confirmed_defects?: unknown[]; cancelled?: boolean }
457
+ return out.confirmed_defects?.length ? 1 : 0
458
+ } finally {
459
+ process.off("SIGINT", onSigint)
460
+ }
461
+ }
462
+
463
+ /** forgeqe notify test [channel]: a test card to every notification channel (or one), to check each arrives. */
464
+ async function cmdNotify(args: string[], flags: Flags): Promise<number> {
465
+ if (args[0] !== "test") throw new Error("Usage: forgeqe notify test [channel]")
466
+ const cfg = loadConfig(configOpts(flags))
467
+ const channels = (cfg.notify?.channels || []).filter((c) => !args[1] || c.name === args[1])
468
+ if (!channels.length) throw new Error(cfg.notify ? `No channel ${args[1]}. Channels: ${cfg.notify.channels.map((c) => c.name).join(", ")}.` : "No notification channels: add notify.channels to forgeqe.yaml (see HOW_TO_USE.md, section 17).")
469
+ const notice: Notice = {
470
+ source: "ForgeQE",
471
+ title: "Test notification",
472
+ status: "good",
473
+ subtitle: `Sent with forgeqe notify test · ${new Date().toLocaleString()}`,
474
+ items: channels.map((c) => ({ line: `Channel ${c.name}`, details: [`Gets: ${c.on.join(", ") || "nothing automatic"}`] })),
475
+ }
476
+ const sent = await Promise.all(channels.map(async (c) => ({ channel: c.name, sent: await sendNotice(c, notice) })))
477
+ console.log(notifiedLine(sent))
478
+ return sent.every((s) => s.sent.every((x) => x.ok)) ? 0 : 1
479
+ }
480
+
481
+ /**
482
+ * forgeqe eval domain [--cases DIR|FILE] [--only a,b] [--variant declared|undeclared] [--repeat N]
483
+ * [--judge-profile NAME] [--min 0.8]: whether ForgeQE's agent applies domain and regulatory knowledge when it designs
484
+ * tests, with the domain declared and without; exits 1 when --min is given and the declared variant falls short.
485
+ */
486
+ async function cmdEval(args: string[], flags: Flags): Promise<number> {
487
+ if (args[0] !== "domain") throw new Error("Usage: forgeqe eval domain [--cases DIR] [--only id,id] [--variant declared|undeclared] [--repeat N] [--judge-profile NAME] [--min 0.8]")
488
+ const cfg = loadConfig(configOpts(flags))
489
+ const why = brainUnavailable(cfg)
490
+ if (why) throw new Error(why)
491
+ const judgeName = strFlag(flags, "judge-profile")
492
+ const judgeConfig = judgeName ? loadConfig({ ...configOpts(flags), profile: judgeName }) : undefined
493
+ const only = (strFlag(flags, "only") || "").split(",").map((s) => s.trim()).filter(Boolean)
494
+ const all = loadDomainCases(path.resolve(strFlag(flags, "cases") || DOMAIN_CASES))
495
+ const cases = only.length ? all.filter((c) => only.includes(c.id)) : all
496
+ if (!cases.length) throw new Error(`No case matches --only ${only.join(",")}. Cases: ${all.map((c) => c.id).join(", ")}.`)
497
+ const variant = strFlag(flags, "variant")
498
+ if (variant && !(VARIANTS as string[]).includes(variant)) throw new Error(`--variant is declared or undeclared, not ${variant}.`)
499
+ const min = strFlag(flags, "min") ? Number(strFlag(flags, "min")) : undefined
500
+ if (min !== undefined && !(min >= 0 && min <= 1)) throw new Error("--min is a share between 0 and 1, e.g. 0.8.")
501
+ const controller = new AbortController()
502
+ const onSigint = () => controller.abort()
503
+ process.once("SIGINT", onSigint)
504
+ try {
505
+ const report = await runDomainEval({
506
+ config: cfg,
507
+ judgeConfig,
508
+ cases,
509
+ variants: variant ? [variant as Variant] : undefined,
510
+ repeat: Number(strFlag(flags, "repeat")) || 1,
511
+ signal: controller.signal,
512
+ onProgress: (text) => console.log(text),
513
+ })
514
+ const dir = saveReport(report, cfg.export.outDir)
515
+ console.log("")
516
+ console.log(reportMarkdown(report).split("\n## ")[0]!.trim())
517
+ console.log("")
518
+ console.log(`Report: ${path.join(dir, "report.md")}`)
519
+ if (min === undefined) return 0
520
+ const failed = gate(report, min)
521
+ console.log(failed ? `Gate failed: ${failed}.` : `Gate passed (at least ${Math.round(min * 100)}% of domain and regulation expectations, no invented ids).`)
522
+ if (failed) {
523
+ const notified = notifiedLine(await notify(cfg, "eval_failed", evalNotice(report, failed, cfg, path.join(dir, "report.md"))))
524
+ if (notified) console.log(notified)
525
+ }
526
+ return failed ? 1 : 0
527
+ } finally {
528
+ process.off("SIGINT", onSigint)
529
+ }
530
+ }
531
+
532
+ /**
533
+ * forgeqe prompts [list] | show <name> | diff <name> | reset <name>: the agents' prompts, where each comes from
534
+ * (ForgeQE's, or an edited copy in .forgeqe/prompts), and how an edited one differs from ForgeQE's.
535
+ */
536
+ function cmdPrompts(args: string[], flags: Flags): number {
537
+ loadConfig(configOpts(flags))
538
+ const action = args[0] || "list"
539
+ const name = args[1]
540
+ if (action === "list") {
541
+ const all = listPrompts()
542
+ const width = Math.max(...all.map((p) => p.name.length))
543
+ for (const p of all) console.log(`${p.name.padEnd(width)} ${p.source === "built-in" ? "ForgeQE's" : `${p.source}: ${path.relative(process.cwd(), p.file) || p.file}`}${p.outdated ? " (based on an older version: forgeqe prompts diff " + p.name + ")" : ""}\n${" ".repeat(width + 2)}${p.description}`)
544
+ console.log(`\nLooked for in: ${promptDirs().map((d) => `${d.dir} (${d.source})`).join(", ")}, then ForgeQE's own.`)
545
+ return 0
546
+ }
547
+ if (!name) throw new Error(`Usage: forgeqe prompts ${action} <name> (forgeqe prompts lists the names)`)
548
+ const where = promptFile(name)
549
+ if (!existsSync(where.file)) throw new Error(`No prompt named "${name}". forgeqe prompts lists them.`)
550
+ if (action === "show") {
551
+ console.log(`# ${name} (${where.source === "built-in" ? "ForgeQE's" : where.file})\n`)
552
+ console.log(boolFlag(flags, "raw") ? readFileSync(where.file, "utf8") : prompt(name))
553
+ return 0
554
+ }
555
+ if (action === "diff") {
556
+ if (where.source === "built-in") {
557
+ console.log(`${name} is ForgeQE's own: nothing to compare.`)
558
+ return 0
559
+ }
560
+ const mine = readFileSync(where.file, "utf8").replace(/^---\n[\s\S]*?\n---\n/, "").trim()
561
+ const diff = lineDiff(builtinPrompt(name), mine)
562
+ console.log(`--- ForgeQE's ${name}\n+++ ${where.file}${where.outdated ? "\n(ForgeQE's changed after your copy was made)" : ""}\n`)
563
+ console.log(diff.length ? diff.join("\n") : "No differences in the prompt text.")
564
+ return 0
565
+ }
566
+ if (action === "reset") {
567
+ if (where.source === "built-in") {
568
+ console.log(`${name} is already ForgeQE's own.`)
569
+ return 0
570
+ }
571
+ renameSync(where.file, `${where.file}.bak`)
572
+ console.log(`${name}: your version moved to ${where.file}.bak; ForgeQE's is used from now on.`)
573
+ return 0
574
+ }
575
+ throw new Error("Usage: forgeqe prompts [list | show <name> [--raw] | diff <name> | reset <name>]")
576
+ }
577
+
578
+ /** forgeqe auth [login [system|ENV_NAME]] | status | logout <system|ENV_NAME> */
579
+ async function cmdAuth(args: string[], flags: Flags): Promise<number> {
580
+ const action = args[0] || "status"
581
+ if (action === "status") {
582
+ console.log(authStatus(configOrNone(flags)).join("\n"))
583
+ return 0
584
+ }
585
+ if (action === "logout") {
586
+ if (!args[1]) throw new Error("Usage: forgeqe auth logout <system>")
587
+ console.log(await signOut(args[1]))
588
+ return 0
589
+ }
590
+ if (action !== "login") throw new Error("Usage: forgeqe auth [login [system]|status|logout <system>]")
591
+ if (!process.stdin.isTTY) throw new Error("forgeqe auth login needs a terminal. In a pipeline, set the tokens as environment variables instead.")
592
+ const controller = new AbortController()
593
+ const onSigint = () => controller.abort()
594
+ process.once("SIGINT", onSigint)
595
+ try {
596
+ const res = await signIn(
597
+ {
598
+ say: (text, kind) => (kind === "err" ? console.error(text) : console.log(kind === "ok" ? `✓ ${text}` : text)),
599
+ input: terminalInput(controller.signal),
600
+ choose: terminalAsk(controller.signal)!,
601
+ openUrl: (url) => {
602
+ console.log(`Opening ${url}`)
603
+ if (!boolFlag(flags, "no-browser")) openFile(url)
604
+ },
605
+ attached: true,
606
+ signal: controller.signal,
607
+ },
608
+ args[1],
609
+ configOrNone(flags),
610
+ )
611
+ return res.ok ? 0 : 1
612
+ } finally {
613
+ process.off("SIGINT", onSigint)
614
+ }
615
+ }
616
+
617
+ function cmdDocs(flags: Flags): number {
618
+ const root = path.resolve(import.meta.dir, "..", "..")
619
+ const guide = path.join("docs", "user-guide.html")
620
+ const docs = ["README.md", "HOW_TO_USE.md", guide, ARCHITECTURE_PAGE, path.join("docs", "ARCHITECTURE.md")]
621
+ const copyTo = strFlag(flags, "copy")
622
+ if (copyTo) {
623
+ const dir = path.resolve(copyTo)
624
+ mkdirSync(dir, { recursive: true })
625
+ for (const doc of docs) {
626
+ mkdirSync(path.dirname(path.join(dir, doc)), { recursive: true })
627
+ copyFileSync(path.join(root, doc), path.join(dir, doc))
628
+ console.log(`copied ${path.join(dir, doc)}`)
629
+ }
630
+ return 0
631
+ }
632
+ for (const doc of docs) console.log(path.join(root, doc))
633
+ if (boolFlag(flags, "guide")) openFile(path.join(root, guide))
634
+ else if (boolFlag(flags, "open")) openFile(path.join(root, ARCHITECTURE_PAGE))
635
+ return 0
636
+ }
637
+
638
+ async function cmdStart(flags: Flags): Promise<number> {
639
+ if (!process.stdout.isTTY) {
640
+ console.error('forgeqe start needs a terminal. Elsewhere use: forgeqe run "<request>" --plain, or forgeqe suite <source...>')
641
+ return 1
642
+ }
643
+ const { startTui } = await import("../tui/main.tsx")
644
+ await startTui({
645
+ configPath: strFlag(flags, "config"),
646
+ profile: strFlag(flags, "profile"),
647
+ url: strFlag(flags, "url"),
648
+ goal: strFlag(flags, "goal"),
649
+ headed: boolFlag(flags, "headed"),
650
+ verbose: boolFlag(flags, "verbose"),
651
+ ...memoryFlags(flags),
652
+ repo: boolFlag(flags, "no-code") ? null : strFlag(flags, "repo") ? path.resolve(strFlag(flags, "repo")!) : undefined,
653
+ })
654
+ await stopMonitorsOnExit((line) => console.log(line))
655
+ return 0
656
+ }
657
+
658
+ /** Background monitors started by the agent live as long as this process; wind their rounds down before exiting. */
659
+ async function stopMonitorsOnExit(log: (line: string) => void) {
660
+ const running = backgroundMonitors()
661
+ if (!running.length) return
662
+ log(`Stopping ${running.length === 1 ? "the monitor" : `${running.length} monitors`}…`)
663
+ await stopBackgroundMonitors()
664
+ }
665
+
666
+ const SKILLS_USAGE = "Usage: forgeqe skills [list|check] · show <name> · new <name> --description TEXT [--user] [--agent browser|scribe|forgeqe] · enable|disable <name>"
667
+
668
+ function cmdSkills(args: string[], flags: Flags): number {
669
+ const cfg = loadConfig(configOpts(flags))
670
+ const repo = strFlag(flags, "repo") ? path.resolve(strFlag(flags, "repo")!) : process.cwd()
671
+ const [sub = "list", name] = args
672
+ const reg = loadSkills(cfg.skills, repo)
673
+ switch (sub) {
674
+ case "list":
675
+ console.log(skillsOverview(reg, cfg.skills, repo))
676
+ return 0
677
+ case "check":
678
+ if (!cfg.skills.enabled) {
679
+ console.log(skillsOverview(reg, cfg.skills, repo))
680
+ return 0
681
+ }
682
+ for (const p of reg.problems) console.log(`✗ ${p}`)
683
+ console.log(reg.problems.length ? `\n${reg.problems.length} problem${reg.problems.length === 1 ? "" : "s"}; ${reg.skills.length} skills usable.` : `✓ ${reg.skills.length} skills, no problems.`)
684
+ return reg.problems.length ? 1 : 0
685
+ case "show": {
686
+ if (!name) throw new Error(SKILLS_USAGE)
687
+ const skill = findSkill(reg, name)
688
+ if (!skill) throw new Error(`No skill named "${name}". ${reg.skills.length ? `Skills: ${reg.skills.map((s) => s.name).join(", ")}.` : "There are no skills yet."}`)
689
+ console.log(skillDetail(skill, reg.disabled.includes(skill)))
690
+ return 0
691
+ }
692
+ case "new": {
693
+ if (!name) throw new Error(SKILLS_USAGE)
694
+ const description = strFlag(flags, "description")
695
+ if (!description) throw new Error(`Say when the skill applies: forgeqe skills new ${name} --description "Use when …". The agents choose skills by their description.`)
696
+ const agents = (strFlag(flags, "agent") || "").split(",").map((a) => a.trim()).filter(Boolean)
697
+ const bad = agents.filter((a) => !SKILL_AGENTS.includes(a as SkillAgent))
698
+ if (bad.length) throw new Error(`--agent takes browser, scribe, or forgeqe (comma-separated); got ${bad.join(", ")}.`)
699
+ const root = boolFlag(flags, "user") ? cfg.skills.userDir : cfg.skills.projectDir
700
+ const file = newSkill(root, { name, description, agents: agents as SkillAgent[] })
701
+ console.log(`✓ ${file}\nWrite the instructions in it, then check it with: forgeqe skills show ${name}`)
702
+ const ignored = boolFlag(flags, "user") ? null : ignoredNote(file)
703
+ if (ignored) console.log(ignored)
704
+ if (!cfg.skills.enabled) console.log("Skills are turned off in forgeqe.yaml; turn them on to use it.")
705
+ return 0
706
+ }
707
+ case "enable":
708
+ case "disable": {
709
+ if (!name) throw new Error(SKILLS_USAGE)
710
+ if (!cfg.configPath) throw new Error("There is no forgeqe.yaml to record this in. Create one with: forgeqe init")
711
+ if (sub === "disable" && cfg.skills.enabled && !findSkill(reg, name)) throw new Error(`No skill named "${name}".`)
712
+ const changed = setSkillDisabled(cfg.configPath, name, sub === "disable")
713
+ console.log(changed ? `✓ ${name} ${sub}d (skills.disable in ${cfg.configPath})` : `${name} is already ${sub}d.`)
714
+ return 0
715
+ }
716
+ default:
717
+ throw new Error(SKILLS_USAGE)
718
+ }
719
+ }
720
+
721
+ function cmdMemory(args: string[], flags: Flags): number {
722
+ const cfg = loadConfig(configOpts(flags))
723
+ const dirFlag = strFlag(flags, "dir")
724
+ const dir = dirFlag ? path.resolve(dirFlag) : cfg.memory.dir
725
+ const [sub = "list", target] = args
726
+ const show = (what: string) =>
727
+ memoryShow(dir, what, { locators: boolFlag(flags, "locators"), limit: boolFlag(flags, "all") ? 0 : 40 })
728
+ switch (sub) {
729
+ case "list":
730
+ console.log(memoryOverview(dir))
731
+ return 0
732
+ case "show":
733
+ if (!target) throw new Error("Usage: forgeqe memory show <host|url> [--locators] [--all]")
734
+ console.log(show(target))
735
+ return 0
736
+ case "flows":
737
+ console.log(memoryFlows(dir, { steps: boolFlag(flags, "steps") }))
738
+ return 0
739
+ case "clear": {
740
+ const files = memoryFilesFor(dir, target)
741
+ if (!files.length) {
742
+ console.log("Nothing to clear.")
743
+ return 0
744
+ }
745
+ if (!boolFlag(flags, "yes")) {
746
+ console.log(`Would delete ${files.length} memory files${target ? ` for ${target}` : ""} in ${dir}.`)
747
+ console.log("Run again with --yes to delete them.")
748
+ return 1
749
+ }
750
+ clearMemory(files)
751
+ console.log(`Deleted ${files.length} memory files.`)
752
+ return 0
753
+ }
754
+ default:
755
+ console.log(show(sub))
756
+ return 0
757
+ }
758
+ }
759
+
760
+ async function cmdRun(args: string[], flags: Flags): Promise<number> {
761
+ let cfg = loadConfig(configOpts(flags))
762
+ const profile = strFlag(flags, "profile")
763
+ if (profile) cfg = selectProfile(cfg, profile)
764
+ cfg = withCodeFlags(cfg, flags)
765
+ const text = [strFlag(flags, "goal"), ...args].filter(Boolean).join(" ").trim()
766
+ if (!text) throw new Error('What should ForgeQE do? forgeqe run "Search hotels in Mumbai on booking.com"')
767
+ const maxSteps = maxStepsFlag(flags)
768
+ if (cfg.llm.provider === "none") throw new Error(NO_MODEL_SET_UP)
769
+ if (boolFlag(flags, "direct")) return runGoal(cfg, flags, text, maxSteps)
770
+ const why = brainUnavailable(cfg)
771
+ if (why) {
772
+ console.log(`ForgeQE's agent is off (${why}), so the request runs as one browser goal.${why.includes("agent runtime") ? ` ${INSTALL_HINT}` : ""}\n`)
773
+ return runGoal(cfg, flags, text, maxSteps)
774
+ }
775
+ return runAgent(cfg, flags, text, maxSteps)
776
+ }
777
+
778
+ async function runAgent(cfg: AppConfig, flags: Flags, text: string, maxSteps: number | undefined): Promise<number> {
779
+ const color = Boolean(process.stdout.isTTY) && !boolFlag(flags, "plain")
780
+ const { cwd, notes, writeTests } = sessionSetup(cfg, repoFlag(flags))
781
+ const controller = new AbortController()
782
+ const emit = toolPrinter(color, (line) => console.log(line), detailOf(flags))
783
+ const onSigint = () => controller.abort()
784
+ process.once("SIGINT", onSigint)
785
+ let brain: Brain | undefined
786
+ try {
787
+ brain = await openBrain({
788
+ config: cfg,
789
+ cwd,
790
+ notes,
791
+ signal: controller.signal,
792
+ context: () => ({
793
+ headed: boolFlag(flags, "headed"),
794
+ maxSteps,
795
+ url: strFlag(flags, "url"),
796
+ ...memoryFlags(flags),
797
+ signal: controller.signal,
798
+ emit,
799
+ session: "once",
800
+ writeTests,
801
+ waitForUser: terminalWait(controller.signal),
802
+ ask: terminalAsk(controller.signal),
803
+ }),
804
+ onEvent: (e) => {
805
+ const line = formatEvent(e, color, detailOf(flags))
806
+ if (line) console.log(line)
807
+ },
808
+ })
809
+ const b = brain
810
+ controller.signal.addEventListener("abort", () => void b.abort(), { once: true })
811
+ const answer = await brain.ask(text)
812
+ if (answer.outcome === "stopped") {
813
+ console.log("\nStopped.")
814
+ return 2
815
+ }
816
+ if (answer.reply) console.log(`\n${answer.reply}`)
817
+ else if (answer.error) console.error(`\n${answer.error}`)
818
+ if (answer.outcome === "needs_input") console.log('\nRun again with the details, e.g. forgeqe run "…", or use forgeqe start to talk it through.')
819
+ return answer.outcome === "done" ? 0 : answer.outcome === "needs_input" ? 3 : 1
820
+ } finally {
821
+ process.off("SIGINT", onSigint)
822
+ await brain?.close()
823
+ }
824
+ }
825
+
826
+ function cmdMcpList(cfg: AppConfig): number {
827
+ const { servers, skipped } = externalMcp(cfg)
828
+ if (!servers.length && !skipped.length) {
829
+ console.log("No MCP servers for other systems. Set sources.azure_devops or sources.jira, or add servers under mcp:, in forgeqe.yaml.")
830
+ }
831
+ for (const s of servers) {
832
+ const c = s.config as { type: string; command?: string[]; url?: string; environment?: Record<string, string>; headers?: Record<string, string> }
833
+ console.log(`✓ ${s.name.padEnd(13)} ${s.label}`)
834
+ console.log(` ${c.type === "local" ? c.command!.join(" ") : c.url}`)
835
+ for (const [k, v] of Object.entries(c.environment || c.headers || {})) {
836
+ console.log(` ${k}: ${v}${/\{env:FORGEQE_/.test(v) ? " (ForgeQE builds this from your token when the agent starts)" : ""}`)
837
+ }
838
+ }
839
+ for (const s of skipped) console.log(`✗ ${s.label}: ${s.why}`)
840
+ console.log(`\nForgeQE's own tools (browser runs, test cases from every source, read_confluence, query_api) are always there.`)
841
+ console.log("Servers connect when ForgeQE's agent starts; the shell and forgeqe run report which ones did.")
842
+ return 0
843
+ }
844
+
845
+ async function cmdMcp(args: string[], flags: Flags): Promise<number> {
846
+ let cfg = loadConfig(configOpts(flags))
847
+ const profile = strFlag(flags, "profile")
848
+ if (profile) cfg = selectProfile(cfg, profile)
849
+ if (args[0] === "list") return cmdMcpList(cfg)
850
+ if (args[0]) throw new Error(`Unknown: forgeqe mcp ${args[0]}. Use forgeqe mcp (serve) or forgeqe mcp list.`)
851
+ const repo = repoFlag(flags)
852
+ const cwd = repo || process.cwd()
853
+ const log = toolPrinter(false, (line) => process.stderr.write(`${line}\n`), detailOf(flags))
854
+ const context = (): ToolContext => ({ config: cfg, cwd, headed: boolFlag(flags, "headed"), maxSteps: maxStepsFlag(flags), url: strFlag(flags, "url"), ...memoryFlags(flags), emit: log, session: "server" })
855
+ const server = createMcpServer({ tools: TOOLS, context, instructions: () => environmentNotes(context()), version: packageVersion() })
856
+ process.stderr.write(`ForgeQE MCP server on stdio (${TOOLS.length} tools, working in ${cwd})\n`)
857
+ await serveStdio(server)
858
+ await stopMonitorsOnExit((line) => process.stderr.write(`${line}\n`))
859
+ return 0
860
+ }
861
+
862
+ async function runGoal(cfg: AppConfig, flags: Flags, goal: string, maxSteps: number | undefined): Promise<number> {
863
+ const out = runDirectory(cfg.export.outDir, strFlag(flags, "out"))
864
+ const t0 = Date.now()
865
+ let steps = 0
866
+ const { result, files } = await executeRun({
867
+ config: cfg,
868
+ goal,
869
+ url: strFlag(flags, "url"),
870
+ headed: boolFlag(flags, "headed"),
871
+ maxSteps,
872
+ outDir: out,
873
+ skills: skillFlags(flags),
874
+ repo: process.cwd(),
875
+ ...memoryFlags(flags),
876
+ onEvent: (event) => {
877
+ if (event.type === "step") steps = Math.max(steps, event.step)
878
+ if (event.type === "done") return
879
+ const line = formatEvent(event, Boolean(process.stdout.isTTY) && !boolFlag(flags, "plain"), detailOf(flags))
880
+ if (line) console.log(line)
881
+ },
882
+ })
883
+ const summary = buildSummary({
884
+ status: result.status,
885
+ text: result.summary,
886
+ finalUrl: result.finalUrl,
887
+ ms: Date.now() - t0,
888
+ tokens: result.usage.input + result.usage.output,
889
+ steps,
890
+ files,
891
+ memory: result.memory,
892
+ usage: result.usage,
893
+ cost: result.cost,
894
+ outcome: result.outcome,
895
+ failedChecks: result.failedChecks,
896
+ quality: result.quality,
897
+ budgetExceeded: result.budgetExceeded,
898
+ })
899
+ console.log("")
900
+ console.log(formatSummary(summary))
901
+ const runCode = result.status === "complete" ? 0 : result.status === "stopped" ? 2 : 1
902
+ // A budget fails the run, but the steps passed: the test is still written.
903
+ const budget = result.budgetExceeded?.length ? 1 : 0
904
+
905
+ const repo = codeRepo(cfg, flags)
906
+ if (!repo || runCode !== 0) return runCode || budget
907
+ const skip = codeSkipReason(cfg, result)
908
+ if (skip) {
909
+ console.log(`\nCoding agent skipped: ${skip}.`)
910
+ return runCode || budget
911
+ }
912
+ console.log("")
913
+ return (await runCodeStep(cfg, flags, repo, out)) || budget
914
+ }
915
+
916
+ function codeRepo(cfg: AppConfig, flags: Flags): string | undefined {
917
+ if (boolFlag(flags, "no-code")) return undefined
918
+ const flag = strFlag(flags, "repo")
919
+ return flag ? path.resolve(flag) : cfg.code.repo
920
+ }
921
+
922
+ function withCodeFlags(cfg: AppConfig, flags: Flags): AppConfig {
923
+ const attempts = strFlag(flags, "attempts")
924
+ if (attempts && !(Number(attempts) >= 1)) throw new Error("--attempts must be a positive number")
925
+ const model = strFlag(flags, "code-model")
926
+ return { ...cfg, code: { ...cfg.code, ...(attempts ? { maxAttempts: Number(attempts) } : {}), ...(model ? { model } : {}) } }
927
+ }
928
+
929
+ async function runCodeStep(cfg: AppConfig, flags: Flags, repo: string, runDir: string): Promise<number> {
930
+ const t0 = Date.now()
931
+ const color = Boolean(process.stdout.isTTY) && !boolFlag(flags, "plain")
932
+ const controller = new AbortController()
933
+ const onSigint = () => controller.abort()
934
+ process.once("SIGINT", onSigint)
935
+ try {
936
+ const result = await runCodeAgent({
937
+ config: withCodeFlags(cfg, flags),
938
+ repo,
939
+ runDir,
940
+ signal: controller.signal,
941
+ onEvent: (event) => {
942
+ const line = formatEvent(event, color, detailOf(flags))
943
+ if (line) console.log(line)
944
+ },
945
+ })
946
+ console.log("")
947
+ console.log(formatCodeSummary(buildCodeSummary(result, Date.now() - t0)))
948
+ return result.status === "passed" ? 0 : result.status === "stopped" ? 2 : 1
949
+ } finally {
950
+ process.off("SIGINT", onSigint)
951
+ }
952
+ }
953
+
954
+ async function cmdCode(args: string[], flags: Flags): Promise<number> {
955
+ let cfg = loadConfig(configOpts(flags))
956
+ const profile = strFlag(flags, "profile")
957
+ if (profile) cfg = selectProfile(cfg, profile)
958
+ const repo = strFlag(flags, "repo") ? path.resolve(strFlag(flags, "repo")!) : cfg.code.repo
959
+ if (!repo) throw new Error("Which repo? Pass --repo DIR or set code.repo in forgeqe.yaml.")
960
+ const runDir = args[0] ? path.resolve(args[0]) : latestRunDir(cfg.export.outDir)
961
+ if (!runDir) throw new Error(`No runs in ${cfg.export.outDir}. Run a goal first, or pass a run directory.`)
962
+ if (cfg.llm.provider === "mock" && !cfg.code.model && !strFlag(flags, "code-model")) {
963
+ throw new Error("The mock profile has no model for the coding agent. Pass --profile, --code-model, or set code.model.")
964
+ }
965
+ if (cfg.llm.provider === "none" && !cfg.code.model && !strFlag(flags, "code-model")) throw new Error(NO_MODEL_SET_UP)
966
+ console.log(`Run ${path.relative(process.cwd(), runDir) || runDir}`)
967
+ return runCodeStep(cfg, flags, repo, runDir)
968
+ }
969
+
970
+ const CASES_USAGE = `Pass test cases: a file (.txt .md .csv .yaml .json .feature), ado:123,456, ado:plan=12[/suite=34],
971
+ jira:SHOP-1,SHOP-2, jira:jql=<query>, xray:SHOP-1, xray:jql=<query>, xray:plan=SHOP-100, testrail:C12,C13,
972
+ testrail:run=45, testrail:plan=7, testrail:suite=3, zephyr:SHOP-T1, zephyr:cycle=SHOP-R5, zephyr:folder=12,
973
+ qtest:1234, qtest:cycle=88, qtest:suite=9, or quoted plain text.`
974
+
975
+ async function cmdCases(refs: string[], flags: Flags): Promise<number> {
976
+ if (!refs.length) throw new Error(CASES_USAGE)
977
+ const cfg = loadConfig(configOpts(flags))
978
+ const cases = await loadCases(refs, cfg.sources)
979
+ const full = boolFlag(flags, "goal")
980
+ for (const tc of cases) {
981
+ console.log(`${tc.id} ${tc.title}${tc.url ? ` ${tc.url}` : ""}`)
982
+ if (full) console.log(`${goalFor(tc).replace(/^/gm, " ")}\n`)
983
+ else if (tc.steps.length) console.log(` ${tc.steps.length} step${tc.steps.length === 1 ? "" : "s"}${tc.startUrl ? ` · starts at ${tc.startUrl}` : ""}`)
984
+ }
985
+ console.log(`\n${cases.length} test case${cases.length === 1 ? "" : "s"}. forgeqe suite ${refs.map((r) => (/\s/.test(r) ? JSON.stringify(r) : r)).join(" ")} runs them.`)
986
+ return 0
987
+ }
988
+
989
+ async function cmdHeal(args: string[], flags: Flags): Promise<number> {
990
+ let cfg = loadConfig(configOpts(flags))
991
+ const profile = strFlag(flags, "profile")
992
+ if (profile) cfg = selectProfile(cfg, profile)
993
+ cfg = withCodeFlags(cfg, flags)
994
+ const why = brainUnavailable(cfg)
995
+ if (why) throw new Error(`forgeqe heal needs the agent runtime and a live model, but ${why}. ${why.includes("agent runtime") ? INSTALL_HINT : "Pick a profile with --profile, or set code.model."}`)
996
+ const repo = strFlag(flags, "repo") ? path.resolve(strFlag(flags, "repo")!) : cfg.code.repo || process.cwd()
997
+ const command = args.join(" ").trim() || strFlag(flags, "command") || undefined
998
+ const color = Boolean(process.stdout.isTTY) && !boolFlag(flags, "plain")
999
+ console.log(`Repo ${repo}`)
1000
+
1001
+ const controller = new AbortController()
1002
+ const onSigint = () => controller.abort()
1003
+ process.once("SIGINT", onSigint)
1004
+ try {
1005
+ const result = await runHeal({
1006
+ config: cfg,
1007
+ repo,
1008
+ command,
1009
+ outDir: strFlag(flags, "out"),
1010
+ headed: boolFlag(flags, "headed"),
1011
+ maxSteps: maxStepsFlag(flags),
1012
+ url: strFlag(flags, "url"),
1013
+ signal: controller.signal,
1014
+ onEvent: (e) => {
1015
+ const line = formatEvent(e, color, detailOf(flags))
1016
+ if (line) console.log(line)
1017
+ },
1018
+ onTool: toolPrinter(color, (line) => console.log(line), detailOf(flags)),
1019
+ })
1020
+ console.log("")
1021
+ console.log(formatHeal(result))
1022
+ return result.status === "passed" || result.status === "clean" ? 0 : result.status === "stopped" ? 2 : 1
1023
+ } finally {
1024
+ process.off("SIGINT", onSigint)
1025
+ }
1026
+ }
1027
+
1028
+ function withMonitorFlags(cfg: AppConfig, flags: Flags): AppConfig {
1029
+ const m = { ...cfg.monitor }
1030
+ const every = strFlag(flags, "every")
1031
+ if (every) {
1032
+ m.everyMs = parseDuration(every, "--every")
1033
+ if (m.everyMs < 10_000) throw new Error("--every must be at least 10s.")
1034
+ }
1035
+ const replay = strFlag(flags, "replay")
1036
+ if (replay) m.replay = replayMode(replay)
1037
+ const parallel = strFlag(flags, "parallel")
1038
+ if (parallel) {
1039
+ if (!(Number(parallel) >= 1)) throw new Error("--parallel must be a positive number")
1040
+ m.parallel = Number(parallel)
1041
+ }
1042
+ const out = strFlag(flags, "out")
1043
+ if (out) m.outDir = path.resolve(out)
1044
+ return { ...cfg, monitor: m }
1045
+ }
1046
+
1047
+ async function cmdMonitor(refs: string[], flags: Flags): Promise<number> {
1048
+ let cfg = loadConfig(configOpts(flags))
1049
+ const profile = strFlag(flags, "profile")
1050
+ if (profile) cfg = selectProfile(cfg, profile)
1051
+ cfg = withMonitorFlags(cfg, flags)
1052
+ const m = cfg.monitor
1053
+ const channels = [m.alerts.teams ? "Teams" : "", m.alerts.email ? `email to ${m.alerts.email.to.join(", ")}` : ""].filter(Boolean)
1054
+
1055
+ if (boolFlag(flags, "test-alerts")) {
1056
+ if (!channels.length) throw new Error("No alert channels are set up. Add monitor.alerts.teams or monitor.alerts.email to forgeqe.yaml.")
1057
+ const sent = await sendTestAlert(cfg)
1058
+ for (const s of sent) console.log(s.ok ? `✓ Test alert sent to ${s.channel === "teams" ? "Teams" : `email (${m.alerts.email!.to.join(", ")})`}` : `✗ ${s.channel === "teams" ? "Teams" : "Email"}: ${s.error}`)
1059
+ return sent.every((s) => s.ok) ? 0 : 1
1060
+ }
1061
+
1062
+ const checks = refs.length ? refs : m.checks
1063
+ if (!checks.length) throw new Error(`What should ForgeQE monitor? Set monitor.checks in forgeqe.yaml, or pass sources.\n${CASES_USAGE}`)
1064
+ const once = boolFlag(flags, "once")
1065
+ const verbose = boolFlag(flags, "verbose")
1066
+ const color = Boolean(process.stdout.isTTY) && !boolFlag(flags, "plain")
1067
+ const every = m.everyMs % 3_600_000 === 0 ? `${m.everyMs / 3_600_000}h` : m.everyMs % 60_000 === 0 ? `${m.everyMs / 60_000}m` : `${Math.round(m.everyMs / 1000)}s`
1068
+ console.log(
1069
+ `${once ? "Checking" : "Monitoring"} ${checks.map((r) => (/\s/.test(r) ? JSON.stringify(r) : r)).join(" ")}${once ? "" : ` every ${every}`} · replay ${m.replay} · alerts: ${channels.length ? channels.join(", ") : "none (set monitor.alerts)"}${once ? "" : " · Ctrl+C stops"}`,
1070
+ )
1071
+
1072
+ const controller = new AbortController()
1073
+ const onSigint = () => controller.abort()
1074
+ process.on("SIGINT", onSigint)
1075
+ process.on("SIGTERM", onSigint)
1076
+ try {
1077
+ return await runMonitor({
1078
+ config: cfg,
1079
+ checks,
1080
+ once,
1081
+ headed: boolFlag(flags, "headed"),
1082
+ url: strFlag(flags, "url"),
1083
+ skills: skillFlags(flags),
1084
+ signal: controller.signal,
1085
+ onEvent: (e) => {
1086
+ const line = verbose ? suiteEventLine(e, m.parallel, color, detailOf(flags)) : null
1087
+ if (line) console.log(line)
1088
+ },
1089
+ onRound: (r, nextAt) => console.log(`${verbose ? "\n" : ""}${formatRound(r, nextAt)}`),
1090
+ })
1091
+ } finally {
1092
+ process.off("SIGINT", onSigint)
1093
+ process.off("SIGTERM", onSigint)
1094
+ }
1095
+ }
1096
+
1097
+ async function cmdSuite(refs: string[], flags: Flags): Promise<number> {
1098
+ if (!refs.length) throw new Error(CASES_USAGE)
1099
+ let cfg = loadConfig(configOpts(flags))
1100
+ const profile = strFlag(flags, "profile")
1101
+ if (profile) cfg = selectProfile(cfg, profile)
1102
+ cfg = withCodeFlags(cfg, flags)
1103
+ const parallelRaw = strFlag(flags, "parallel")
1104
+ if (parallelRaw && !(Number(parallelRaw) >= 1)) throw new Error("--parallel must be a positive number")
1105
+ const parallel = parallelRaw ? Number(parallelRaw) : cfg.suite.parallel
1106
+ const cases = await loadCases(refs, cfg.sources)
1107
+ const repo = codeRepo(cfg, flags)
1108
+ const color = Boolean(process.stdout.isTTY) && !boolFlag(flags, "plain")
1109
+ console.log(`${cases.length} test case${cases.length === 1 ? "" : "s"}${parallel > 1 ? `, ${parallel} at a time` : ""}${repo ? `; passed cases get a test in ${repo}` : ""}`)
1110
+
1111
+ const controller = new AbortController()
1112
+ const onSigint = () => controller.abort()
1113
+ process.once("SIGINT", onSigint)
1114
+ try {
1115
+ const suite = await runSuite({
1116
+ config: cfg,
1117
+ cases,
1118
+ repo,
1119
+ parallel,
1120
+ outDir: strFlag(flags, "out"),
1121
+ writeBack: boolFlag(flags, "no-write-back") ? false : undefined,
1122
+ headed: boolFlag(flags, "headed"),
1123
+ url: strFlag(flags, "url"),
1124
+ skills: skillFlags(flags),
1125
+ skillsRepo: repo || process.cwd(),
1126
+ ...memoryFlags(flags),
1127
+ signal: controller.signal,
1128
+ onEvent: (e) => {
1129
+ const line = suiteEventLine(e, parallel, color, detailOf(flags))
1130
+ if (line) console.log(line)
1131
+ },
1132
+ })
1133
+ console.log("")
1134
+ console.log(formatSuite(suite))
1135
+ writeJobSummary(suite)
1136
+ const notified = notifiedLine(await notifySuite(cfg, suite))
1137
+ if (notified) console.log(notified)
1138
+ return suiteExitCode(suite)
1139
+ } finally {
1140
+ process.off("SIGINT", onSigint)
1141
+ }
1142
+ }