@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
package/src/init.ts ADDED
@@ -0,0 +1,102 @@
1
+ import { spawnSync } from "node:child_process"
2
+ import { appendFileSync, existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from "node:fs"
3
+ import os from "node:os"
4
+ import path from "node:path"
5
+
6
+ import { forgeqeHome, projectDir } from "./tools.ts"
7
+
8
+ import { userConfigDir } from "./config.ts"
9
+ import { KNOWLEDGE_TEMPLATES } from "./knowledge.ts"
10
+ import { copyManaged } from "./managed.ts"
11
+ import { PROMPTS_DIR } from "./prompts.ts"
12
+ import { PREBUILT_DIR } from "./skills/registry.ts"
13
+
14
+ const ROOT = path.resolve(import.meta.dir, "..")
15
+
16
+ export function initWorkspace(
17
+ directory: string,
18
+ opts: { force?: boolean; profile?: string },
19
+ ): { created: string[]; notes: string[] } {
20
+ mkdirSync(directory, { recursive: true })
21
+ const created: string[] = []
22
+ const notes: string[] = []
23
+ const profile = opts.profile
24
+
25
+ const pairs: Array<[string, string, (body: string) => string]> = [
26
+ ["llms.example.json", "llms.json", (body) => body],
27
+ [
28
+ "forgeqe.example.yaml",
29
+ "forgeqe.yaml",
30
+ (body) => (profile ? body.replace(/^ {2}# active: .*$/m, ` active: ${profile}`) : body),
31
+ ],
32
+ ]
33
+
34
+ const globalModels = path.join(userConfigDir(), "llms.json")
35
+ for (const [srcName, destName, map] of pairs) {
36
+ const src = path.join(ROOT, srcName)
37
+ const dest = path.join(directory, destName)
38
+ if (destName === "llms.json" && !existsSync(dest) && existsSync(globalModels) && path.resolve(directory) !== userConfigDir()) {
39
+ notes.push(`Using your profiles from ${globalModels}; no project llms.json written.`)
40
+ continue
41
+ }
42
+ if (!existsSync(src)) {
43
+ notes.push(`Template missing: ${src}`)
44
+ continue
45
+ }
46
+ if (existsSync(dest) && !opts.force) {
47
+ notes.push(`Kept existing ${dest}`)
48
+ continue
49
+ }
50
+ if (destName === "forgeqe.yaml") {
51
+ writeFileSync(dest, map(readFileSync(src, "utf8")), "utf8")
52
+ } else {
53
+ writeFileSync(dest, map(readFileSync(src, "utf8")), "utf8")
54
+ }
55
+ created.push(dest)
56
+ }
57
+
58
+ const home = path.resolve(directory) === userConfigDir()
59
+ if (!home) {
60
+ const ignored = ignoreReports(directory)
61
+ if (ignored) notes.push(ignored)
62
+ }
63
+
64
+ // Prompts, skills, and product knowledge templates, to read and change: .forgeqe/ in a project, ~/.forgeqe for the user.
65
+ const root = home ? forgeqeHome() : projectDir(directory)
66
+ const shown = (p: string) => (home ? p : path.relative(directory, p) || p)
67
+ for (const { what, src, dest, keys } of [
68
+ { what: "prompts", src: PROMPTS_DIR, dest: path.join(root, "prompts"), keys: entries(PROMPTS_DIR, (e) => e.endsWith(".md")) },
69
+ { what: "skills", src: PREBUILT_DIR, dest: path.join(root, "skills"), keys: entries(PREBUILT_DIR, (e) => existsSync(path.join(PREBUILT_DIR, e, "SKILL.md"))) },
70
+ { what: "product knowledge templates", src: KNOWLEDGE_TEMPLATES, dest: path.join(root, "knowledge"), keys: entries(KNOWLEDGE_TEMPLATES, (e) => /\.(md|txt)$/.test(e)) },
71
+ ]) {
72
+ const { copied, kept } = copyManaged(src, dest, keys, { force: opts.force })
73
+ if (copied.length) created.push(`${dest}/ (${copied.length} ${what})`)
74
+ if (kept.length) notes.push(`Kept ${kept.length} existing ${what} in ${shown(dest)}${opts.force ? " (edited ones are never overwritten)" : ""}`)
75
+ }
76
+
77
+ const known = ["azure-gpt4o", "azure-gpt4o-entra", "openai-gpt4o", "ollama-llama", "compatible"]
78
+ if (profile && !known.includes(profile)) {
79
+ notes.push(`Profile "${profile}" is not in the template llms.json. Add it before running.`)
80
+ }
81
+ return { created, notes }
82
+ }
83
+
84
+ function entries(dir: string, keep: (name: string) => boolean): string[] {
85
+ try {
86
+ return readdirSync(dir).filter((e) => !e.startsWith(".") && keep(e)).sort()
87
+ } catch {
88
+ return []
89
+ }
90
+ }
91
+
92
+ /** Adds .forgeqe/reports/ (.webpilot/reports/ in a project from before the rename) to the folder's .gitignore unless git already ignores it; the note to show, or null. */
93
+ export function ignoreReports(directory: string): string | null {
94
+ const REPORTS_IGNORE = `${path.basename(projectDir(directory))}/reports/`
95
+ const check = spawnSync("git", ["check-ignore", "-q", `${REPORTS_IGNORE}run`], { cwd: directory, stdio: "ignore" })
96
+ if (check.status === 0) return null
97
+ const file = path.join(directory, ".gitignore")
98
+ const body = existsSync(file) ? readFileSync(file, "utf8") : ""
99
+ if (body.split(/\r?\n/).some((l) => l.trim() === REPORTS_IGNORE)) return null
100
+ appendFileSync(file, `${body && !body.endsWith("\n") ? "\n" : ""}${body ? "\n" : ""}# ForgeQE run reports (steps, screenshots, traces)\n${REPORTS_IGNORE}\n`)
101
+ return `Added ${REPORTS_IGNORE} to ${file}`
102
+ }
@@ -0,0 +1,114 @@
1
+ import { existsSync, readdirSync, readFileSync, statSync } from "node:fs"
2
+ import path from "node:path"
3
+
4
+ import { copyState, MANIFEST } from "./managed.ts"
5
+
6
+ /**
7
+ * Product knowledge: markdown or text files in .forgeqe/knowledge/ (knowledge.dir) about the product: what it does,
8
+ * roles, glossary, business rules, test data, environments. ForgeQE's agent sees the list and reads what a request
9
+ * needs; planners and the browser agent get the sections that match their task. A template copy nobody filled in
10
+ * (unchanged since forgeqe init) is left out.
11
+ */
12
+
13
+ export const KNOWLEDGE_TEMPLATES = path.resolve(import.meta.dir, "..", "knowledge")
14
+ const MAX_FILE = 200_000
15
+ const STOP = new Set(
16
+ "the a an and or of to in on for with is are be should must can will user users when then that this from into able page shown show shows display displayed after before your their there what which while about have has not".split(" "),
17
+ )
18
+
19
+ export type KnowledgeFile = { file: string; rel: string; title: string; text: string }
20
+ export type Section = { rel: string; heading: string; text: string }
21
+
22
+ function walk(dir: string, at = ""): string[] {
23
+ let entries: string[]
24
+ try {
25
+ entries = readdirSync(path.join(dir, at))
26
+ } catch {
27
+ return []
28
+ }
29
+ const out: string[] = []
30
+ for (const e of entries.sort()) {
31
+ if (e.startsWith(".") || e === MANIFEST) continue
32
+ const rel = path.join(at, e)
33
+ const full = path.join(dir, rel)
34
+ if (statSync(full).isDirectory()) out.push(...walk(dir, rel))
35
+ else if (/\.(md|txt)$/i.test(e) && e.toLowerCase() !== "readme.md") out.push(rel)
36
+ }
37
+ return out
38
+ }
39
+
40
+ /** The knowledge files that have content of their own (not an untouched template). */
41
+ export function readKnowledge(dir: string | undefined): KnowledgeFile[] {
42
+ if (!dir || !existsSync(dir)) return []
43
+ return walk(dir)
44
+ .filter((rel) => copyState(dir, rel) !== "unchanged" && statSync(path.join(dir, rel)).size <= MAX_FILE)
45
+ .map((rel) => {
46
+ const text = readFileSync(path.join(dir, rel), "utf8").replace(/<!--[\s\S]*?-->/g, "").trim()
47
+ const title = text.match(/^#\s+(.+)$/m)?.[1]?.trim() || rel.replace(/\.(md|txt)$/i, "")
48
+ return { file: path.join(dir, rel), rel: rel.split(path.sep).join("/"), title, text }
49
+ })
50
+ .filter((k) => k.text.replace(/^#.*$/gm, "").trim().length > 0)
51
+ }
52
+
53
+ /** Each file split at its ## (or deeper) headings, so a task gets only the parts it needs. */
54
+ export function sections(files: KnowledgeFile[]): Section[] {
55
+ const out: Section[] = []
56
+ for (const f of files) {
57
+ const parts = f.text.split(/\n(?=#{2,4}\s)/)
58
+ for (const p of parts) {
59
+ const heading = p.match(/^#{1,4}\s+(.+)$/m)?.[1]?.trim() || f.title
60
+ const text = p.trim()
61
+ if (text.replace(/^#.*$/gm, "").trim()) out.push({ rel: f.rel, heading: heading === f.title ? f.title : `${f.title} › ${heading}`, text })
62
+ }
63
+ }
64
+ return out
65
+ }
66
+
67
+ /** A word without its plural or past ending, so "addresses" matches "address" and "refused" matches "refuse". */
68
+ function stem(w: string): string {
69
+ if (w.endsWith("sses")) return w.slice(0, -2)
70
+ if (w.endsWith("ies") && w.length > 4) return `${w.slice(0, -3)}y`
71
+ if (w.endsWith("ing") && w.length > 5) return w.slice(0, -3)
72
+ if (w.endsWith("ed") && w.length > 4) return w.slice(0, -2)
73
+ if (w.endsWith("s") && !w.endsWith("ss") && w.length > 4) return w.slice(0, -1)
74
+ return w
75
+ }
76
+
77
+ const words = (s: string) => new Set(s.toLowerCase().split(/[^a-z0-9]+/).filter((w) => w.length >= 4 && !STOP.has(w)).map(stem))
78
+
79
+ /**
80
+ * The sections that share the most words with the task, most relevant first, up to `maxChars`. `minHits` leaves out
81
+ * sections that share fewer words (the browser agent asks for 2, so only clearly related parts reach every step).
82
+ */
83
+ export function relevantKnowledge(files: KnowledgeFile[], task: string, maxChars: number, minHits = 1): Section[] {
84
+ const want = words(task)
85
+ if (!want.size || maxChars <= 0) return []
86
+ const scored = sections(files)
87
+ .map((s) => {
88
+ const have = words(`${s.heading} ${s.text}`)
89
+ let hits = 0
90
+ for (const w of want) if (have.has(w)) hits++
91
+ return { s, hits }
92
+ })
93
+ .filter((x) => x.hits >= minHits)
94
+ .sort((a, b) => b.hits - a.hits)
95
+ const out: Section[] = []
96
+ let used = 0
97
+ for (const { s } of scored) {
98
+ if (used + s.text.length > maxChars) continue
99
+ out.push(s)
100
+ used += s.text.length
101
+ }
102
+ return out
103
+ }
104
+
105
+ /** Sections as a prompt block, or "" when there are none. */
106
+ export function knowledgeBlock(found: Section[], title = "Product knowledge"): string {
107
+ if (!found.length) return ""
108
+ return [`## ${title}`, "", "From the team's notes about the product (.forgeqe/knowledge). Use them where they apply; the live application wins where they disagree.", "", ...found.map((s) => `### ${s.heading} (${s.rel})\n${s.text.replace(/^#{1,4}\s+.*\n?/, "").trim()}`)].join("\n")
109
+ }
110
+
111
+ /** One line per file, for ForgeQE's agent to pick what to read. */
112
+ export function knowledgeIndex(files: KnowledgeFile[]): string[] {
113
+ return files.map((f) => `- ${f.file}: ${f.title} (${Math.max(1, Math.round(f.text.length / 1000))} KB)`)
114
+ }
@@ -0,0 +1,9 @@
1
+ /**
2
+ * ForgeQE was called WebPilot: each WEBPILOT_* variable still works as its FORGEQE_* name, which wins when both are set.
3
+ * The entry point imports this first, before anything reads the environment.
4
+ */
5
+ for (const [key, value] of Object.entries(process.env)) {
6
+ if (!key.startsWith("WEBPILOT_") || value === undefined) continue
7
+ const name = `FORGEQE_${key.slice("WEBPILOT_".length)}`
8
+ if (process.env[name] === undefined) process.env[name] = value
9
+ }
package/src/llm.ts ADDED
@@ -0,0 +1,61 @@
1
+ import type { Price } from "./usage.ts"
2
+
3
+ /**
4
+ * One entry of llms.json, driven by the ForgeQE engine. `none` stands in when no profile is set up;
5
+ * `mock` is the offline test double, chosen only with --profile mock or WP_LLM_PROFILE=mock.
6
+ */
7
+ export type LlmProfile = {
8
+ provider: "none" | "mock" | "openai" | "azure" | "ollama" | "openai_compatible"
9
+ model: string
10
+ endpoint?: string
11
+ deployment?: string
12
+ apiKey?: string
13
+ apiKeyEnv?: string
14
+ /** How ForgeQE signs in to the model: an API key (the default), or Microsoft Entra ID with no key at all. */
15
+ auth?: "key" | "entra"
16
+ /** auth: entra — the client id of the user-assigned managed identity to use; else the machine's default identity. */
17
+ managedIdentityClientId?: string
18
+ /** auth: entra — the token's scope; default Azure OpenAI's. A gateway such as API Management may want its own. */
19
+ tokenScope?: string
20
+ apiVersion?: string
21
+ temperature?: number
22
+ /** Price per million tokens, for the cost shown in reports: llms.json's, else the model's list price. */
23
+ price?: Price
24
+ /** llms.json's price differs from the model's list price: what to tell the user. */
25
+ priceWarning?: string
26
+ }
27
+
28
+ /**
29
+ * Why this profile has no API key in this environment, or null. Checked before any request, so a missing key
30
+ * names its variable instead of coming back as the provider's "access denied".
31
+ */
32
+ export const NO_MODEL_SET_UP =
33
+ "No model is set up yet. Add a profile with your provider and key to llms.json (forgeqe init writes one to fill in), " +
34
+ "then pick it with llm.active in forgeqe.yaml or Tab in the shell. forgeqe models lists the profiles ForgeQE sees."
35
+
36
+ /** Why ForgeQE's agent or the coding agent has no model to run on (code.model can supply one), or null. */
37
+ export function noAgentModel(p: LlmProfile, codeModel: string | undefined, what: string): string | null {
38
+ if (codeModel) return null
39
+ if (p.provider === "none") return NO_MODEL_SET_UP
40
+ if (p.provider === "mock") return `The mock profile has no model for ${what}. Pick a live profile, or set code.model.`
41
+ return null
42
+ }
43
+
44
+ /** The providers that can sign in with Microsoft Entra ID instead of a key. */
45
+ export const ENTRA_PROVIDERS: LlmProfile["provider"][] = ["azure", "openai_compatible"]
46
+
47
+ export function missingKey(p: LlmProfile): string | null {
48
+ if (p.provider === "none") return NO_MODEL_SET_UP
49
+ if (p.auth === "entra") {
50
+ // The credential is found when the first request is made: managed identity, workload identity or az login.
51
+ return ENTRA_PROVIDERS.includes(p.provider) ? null : `auth: entra works with the azure and openai_compatible providers, not ${p.provider} (profile for ${p.model}).`
52
+ }
53
+ if (p.provider !== "azure" && p.provider !== "openai") return null
54
+ if (p.apiKey?.trim()) return null
55
+ if (!p.apiKeyEnv) return `The ${p.provider} profile for ${p.model} has no api_key_env in llms.json, so it has no API key.`
56
+ if (process.env[p.apiKeyEnv]?.trim()) return null
57
+ return (
58
+ `The ${p.provider} profile for ${p.model} reads its API key from ${p.apiKeyEnv}, which is not set in this terminal. ` +
59
+ `Set ${p.apiKeyEnv} (or put it in a .env file next to forgeqe.yaml), or change api_key_env in llms.json to the variable that holds your key.`
60
+ )
61
+ }
package/src/locale.ts ADDED
@@ -0,0 +1,25 @@
1
+ import { execFileSync } from "node:child_process"
2
+
3
+ function envLocale(): string | undefined {
4
+ const raw = process.env.LC_ALL || process.env.LC_MONETARY || process.env.LANG || ""
5
+ if (!raw || raw === "C" || raw === "POSIX" || raw.startsWith("C.")) return undefined
6
+ return raw.split(".")[0].replaceAll("_", "-")
7
+ }
8
+
9
+ let cached: string | undefined
10
+
11
+ /** The language this machine is set to. A C/POSIX shell locale is ignored. */
12
+ export function systemLocale(): string {
13
+ if (cached) return cached
14
+ const fromEnv = envLocale()
15
+ if (fromEnv) return (cached = fromEnv)
16
+ if (process.platform === "darwin") {
17
+ try {
18
+ const apple = execFileSync("defaults", ["read", "-g", "AppleLocale"], { encoding: "utf8" }).trim()
19
+ if (apple) return (cached = apple.replaceAll("_", "-"))
20
+ } catch {
21
+ /* defaults is missing */
22
+ }
23
+ }
24
+ return (cached = Intl.DateTimeFormat().resolvedOptions().locale || "en-US")
25
+ }
package/src/logins.ts ADDED
@@ -0,0 +1,148 @@
1
+ import { spawn } from "node:child_process"
2
+ import { existsSync, rmSync, statSync } from "node:fs"
3
+ import { createInterface } from "node:readline"
4
+
5
+ import { loginEnvNames } from "./childEnv.ts"
6
+ import type { AppConfig, LoginConfig } from "./config.ts"
7
+ import { loginPayload } from "./engine/bridge.ts"
8
+ import { engineEnv, ensureEngine } from "./engine/setup.ts"
9
+
10
+ export const LOGINS_USAGE = `Saved logins come from logins: in forgeqe.yaml, for example
11
+ logins:
12
+ shop:
13
+ url: https://staging.shop.example/login # the site it signs in to (or domains: [...])
14
+ username_env: SHOP_USER
15
+ password_env: SHOP_PASSWORD
16
+ totp_env: SHOP_TOTP_SECRET # optional: the authenticator secret (base32)
17
+
18
+ forgeqe login <name> [--url URL] sign in once by hand in a browser window; the session is reused by runs
19
+ forgeqe logins list logins and their saved sessions
20
+ forgeqe logins clear <name|--all> forget saved sessions`
21
+
22
+ /** Where `forgeqe login` opens the browser: --url, the login's url, or its first domain without wildcards. */
23
+ export function loginStartUrl(login: LoginConfig, url?: string): string {
24
+ const pick = url || login.url || login.domains.find((d) => !d.includes("*"))
25
+ if (!pick) throw new Error(`Login "${login.name}" has only wildcard domains; pass --url or set url: in forgeqe.yaml.`)
26
+ return /^[a-z]+:\/\//i.test(pick) ? pick : `https://${pick}`
27
+ }
28
+
29
+ export function describeLogins(cfg: AppConfig): string {
30
+ if (!cfg.logins.length) return `No saved logins.\n\n${LOGINS_USAGE}`
31
+ const lines = cfg.logins.map((l) => {
32
+ const envs = [l.usernameEnv, l.passwordEnv, l.totpEnv].filter((e): e is string => Boolean(e))
33
+ const missing = envs.filter((e) => !process.env[e])
34
+ const session = !l.sessionFile ? "not saved (save_session: false)" : existsSync(l.sessionFile) ? `saved ${statSync(l.sessionFile).mtime.toISOString().slice(0, 16).replace("T", " ")}` : "none yet"
35
+ return [
36
+ `${l.name} ${l.domains.join(", ")}`,
37
+ ` credentials ${envs.length ? envs.join(", ") : "none (session only)"}${missing.length ? ` (not set: ${missing.join(", ")})` : ""}`,
38
+ ` session ${session}${l.sessionFile ? ` ${l.sessionFile}` : ""}`,
39
+ ].join("\n")
40
+ })
41
+ return lines.join("\n\n")
42
+ }
43
+
44
+ export function clearLogins(cfg: AppConfig, name: string | undefined, all: boolean): string {
45
+ const picked = all ? cfg.logins : cfg.logins.filter((l) => l.name === name)
46
+ if (!picked.length) throw new Error(name ? `No login "${name}" in forgeqe.yaml.` : "Name a login, or pass --all.")
47
+ const removed = picked.filter((l) => l.sessionFile && existsSync(l.sessionFile))
48
+ for (const l of removed) rmSync(l.sessionFile!, { force: true })
49
+ return removed.length ? `Forgot the saved session for ${removed.map((l) => l.name).join(", ")}.` : "No saved sessions to forget."
50
+ }
51
+
52
+ export type SignInResult = { status: "saved" | "cancelled" | "failed"; text: string; sessionFile?: string }
53
+
54
+ /**
55
+ * Open a visible browser on the login's page with any saved session, let the user sign in (SSO, captcha, security
56
+ * keys: whatever the site asks), and save cookies and local storage once `wait` resolves true (false cancels).
57
+ */
58
+ export async function signIn(
59
+ cfg: AppConfig,
60
+ name: string | undefined,
61
+ io: { url?: string; log?: (text: string) => void; wait: (prompt: string) => Promise<boolean>; signal?: AbortSignal },
62
+ ): Promise<SignInResult> {
63
+ const login = cfg.logins.find((l) => l.name === name)
64
+ if (!login) throw new Error(`${name ? `No login "${name}" in forgeqe.yaml.` : "Name the login to sign in to."}\n\n${LOGINS_USAGE}`)
65
+ if (!login.sessionFile) throw new Error(`Login "${login.name}" has save_session: false, so there is nowhere to keep the session.`)
66
+ const log = io.log || (() => {})
67
+ const start = loginStartUrl(login, io.url)
68
+ const python = await ensureEngine(log)
69
+ const child = spawn(python, ["-m", "forgeqe_engine", "login"], { env: engineEnv(undefined, loginEnvNames([login])), stdio: ["pipe", "pipe", "pipe"] })
70
+ const stderrTail: string[] = []
71
+ child.stderr.setEncoding("utf8")
72
+ child.stderr.on("data", (chunk: string) => {
73
+ stderrTail.push(...chunk.split("\n").filter(Boolean))
74
+ stderrTail.splice(0, Math.max(0, stderrTail.length - 20))
75
+ })
76
+ child.stdin.write(`${JSON.stringify({ url: start, logins: loginPayload([login]), overlay: cfg.browser.overlay })}\n`)
77
+
78
+ const send = (cmd: string) => {
79
+ if (!child.stdin.destroyed) child.stdin.write(`${cmd}\n`)
80
+ }
81
+ let interrupted = false
82
+ const onAbort = () => {
83
+ interrupted = true
84
+ send("stop")
85
+ }
86
+ io.signal?.addEventListener("abort", onAbort, { once: true })
87
+
88
+ let result: { success?: boolean; error?: string; summary?: string } | undefined
89
+ const events = createInterface({ input: child.stdout })
90
+ events.on("line", (line) => {
91
+ let e: { type?: string; text?: string; result?: typeof result }
92
+ try {
93
+ e = JSON.parse(line)
94
+ } catch {
95
+ return
96
+ }
97
+ if (e.type === "log" && e.text) {
98
+ log(e.text)
99
+ if (e.text.startsWith("Browser open")) {
100
+ void io.wait(`Sign in to ${login.name} in the browser window, then save the session.`).then((ok) => {
101
+ if (!ok) interrupted = true
102
+ send(ok ? "save" : "stop")
103
+ })
104
+ }
105
+ } else if (e.type === "done") result = e.result
106
+ })
107
+ const code = await new Promise<number | null>((resolve) => child.on("close", resolve))
108
+ io.signal?.removeEventListener("abort", onAbort)
109
+ if (result?.success) {
110
+ return {
111
+ status: "saved",
112
+ sessionFile: login.sessionFile,
113
+ text: `${result.summary}: ${login.sessionFile}\nRuns on these domains start signed in. The file holds your session cookies; keep it out of git.`,
114
+ }
115
+ }
116
+ if (result?.error === "cancelled" || (!result && interrupted)) return { status: "cancelled", text: "Cancelled; nothing was saved." }
117
+ return { status: "failed", text: result?.error || `The browser exited (${code}).${stderrTail.length ? `\n${stderrTail.slice(-8).join("\n")}` : ""}` }
118
+ }
119
+
120
+ /** `forgeqe login <name>`: sign in by hand, pressing Enter in the terminal to save (Ctrl+C cancels). */
121
+ export async function interactiveLogin(cfg: AppConfig, name: string | undefined, url?: string): Promise<number> {
122
+ const keys = createInterface({ input: process.stdin })
123
+ const controller = new AbortController()
124
+ const onSigint = () => controller.abort()
125
+ process.on("SIGINT", onSigint)
126
+ try {
127
+ const r = await signIn(cfg, name, {
128
+ url,
129
+ signal: controller.signal,
130
+ log: (text) => console.log(text),
131
+ wait: (prompt) =>
132
+ new Promise((resolve) => {
133
+ console.log(`${prompt} Press Enter here to save (Ctrl+C cancels).`)
134
+ keys.once("line", () => resolve(true))
135
+ controller.signal.addEventListener("abort", () => resolve(false), { once: true })
136
+ }),
137
+ })
138
+ if (r.status === "failed") console.error(r.text)
139
+ else console.log(r.text)
140
+ return r.status === "saved" ? 0 : r.status === "cancelled" ? 2 : 1
141
+ } catch (err) {
142
+ console.error((err as Error).message)
143
+ return 1
144
+ } finally {
145
+ process.off("SIGINT", onSigint)
146
+ keys.close()
147
+ }
148
+ }
package/src/managed.ts ADDED
@@ -0,0 +1,101 @@
1
+ import { createHash } from "node:crypto"
2
+ import { copyFileSync, existsSync, mkdirSync, readdirSync, readFileSync, statSync, writeFileSync } from "node:fs"
3
+ import path from "node:path"
4
+
5
+ /**
6
+ * Copies ForgeQE puts into a project (prompts, skills, product knowledge templates) and a manifest of what each
7
+ * looked like when copied. A copy nobody changed is still ForgeQE's: the built-in one is used, so it keeps getting
8
+ * ForgeQE's improvements. A copy someone edited is theirs and wins; when the built-in changed after the copy was made,
9
+ * it is reported as based on an older version.
10
+ */
11
+
12
+ export const MANIFEST = ".forgeqe-manifest.json"
13
+ /** The manifest's name before ForgeQE was renamed from WebPilot; still read. */
14
+ const LEGACY_MANIFEST = ".webpilot-manifest.json"
15
+
16
+ type Entry = { sha: string; at: string }
17
+ type Manifest = { version: 1; note: string; entries: Record<string, Entry> }
18
+
19
+ const NOTE = "Written by forgeqe init: the hash of each file or folder as it was copied. A copy you have not changed follows ForgeQE's built-in version; once you edit it, yours is used. Keep this file."
20
+
21
+ export const sha = (data: string | Buffer) => createHash("sha256").update(data).digest("hex").slice(0, 16)
22
+
23
+ function files(dir: string, at = ""): string[] {
24
+ const out: string[] = []
25
+ for (const e of readdirSync(path.join(dir, at)).sort()) {
26
+ if (e === MANIFEST || e === LEGACY_MANIFEST || e.startsWith(".")) continue
27
+ const rel = path.join(at, e)
28
+ if (statSync(path.join(dir, rel)).isDirectory()) out.push(...files(dir, rel))
29
+ else out.push(rel)
30
+ }
31
+ return out
32
+ }
33
+
34
+ /** A file's hash, or a folder's (every file in it, by relative path and content). */
35
+ export function hashOf(p: string): string {
36
+ if (!existsSync(p)) return ""
37
+ if (!statSync(p).isDirectory()) return sha(readFileSync(p).toString("utf8").replace(/\r\n/g, "\n"))
38
+ return sha(files(p).map((f) => `${f.split(path.sep).join("/")}\n${readFileSync(path.join(p, f)).toString("utf8").replace(/\r\n/g, "\n")}`).join("\n\0"))
39
+ }
40
+
41
+ export function readManifest(dir: string): Manifest {
42
+ try {
43
+ const file = existsSync(path.join(dir, MANIFEST)) ? MANIFEST : LEGACY_MANIFEST
44
+ const m = JSON.parse(readFileSync(path.join(dir, file), "utf8")) as Manifest
45
+ return { version: 1, note: NOTE, entries: m?.entries && typeof m.entries === "object" ? m.entries : {} }
46
+ } catch {
47
+ return { version: 1, note: NOTE, entries: {} }
48
+ }
49
+ }
50
+
51
+ /** How a copy in `dir` stands: missing, unchanged since it was copied (ForgeQE's), or edited (the user's). */
52
+ export function copyState(dir: string, key: string): "missing" | "unchanged" | "edited" | "own" {
53
+ const p = path.join(dir, key)
54
+ if (!existsSync(p)) return "missing"
55
+ const entry = readManifest(dir).entries[key]
56
+ if (!entry) return "own"
57
+ return hashOf(p) === entry.sha ? "unchanged" : "edited"
58
+ }
59
+
60
+ /** An edited copy whose built-in changed after it was copied: the user's version is based on an older one. */
61
+ export function basedOnOlder(dir: string, key: string, builtin: string): boolean {
62
+ const entry = readManifest(dir).entries[key]
63
+ return Boolean(entry) && copyState(dir, key) === "edited" && hashOf(builtin) !== entry.sha
64
+ }
65
+
66
+ function copyTree(src: string, dest: string): void {
67
+ if (!statSync(src).isDirectory()) {
68
+ mkdirSync(path.dirname(dest), { recursive: true })
69
+ copyFileSync(src, dest)
70
+ return
71
+ }
72
+ for (const f of files(src)) {
73
+ mkdirSync(path.dirname(path.join(dest, f)), { recursive: true })
74
+ copyFileSync(path.join(src, f), path.join(dest, f))
75
+ }
76
+ }
77
+
78
+ /**
79
+ * Copy each of `keys` (files or folders under `srcRoot`) into `destRoot`, and record them in its manifest. What is
80
+ * already there is kept unless `force`, and then only when it is an unchanged copy: an edited one is never overwritten.
81
+ */
82
+ export function copyManaged(srcRoot: string, destRoot: string, keys: string[], opts: { force?: boolean } = {}): { copied: string[]; kept: string[] } {
83
+ mkdirSync(destRoot, { recursive: true })
84
+ const m = readManifest(destRoot)
85
+ const copied: string[] = []
86
+ const kept: string[] = []
87
+ for (const key of keys) {
88
+ const src = path.join(srcRoot, key)
89
+ if (!existsSync(src)) continue
90
+ const state = copyState(destRoot, key)
91
+ if (state !== "missing" && !(opts.force && state === "unchanged")) {
92
+ kept.push(key)
93
+ continue
94
+ }
95
+ copyTree(src, path.join(destRoot, key))
96
+ m.entries[key] = { sha: hashOf(src), at: new Date().toISOString() }
97
+ copied.push(key)
98
+ }
99
+ if (copied.length) writeFileSync(path.join(destRoot, MANIFEST), `${JSON.stringify({ version: 1, note: NOTE, entries: Object.fromEntries(Object.entries(m.entries).sort(([a], [b]) => a.localeCompare(b))) }, null, 2)}\n`)
100
+ return { copied, kept }
101
+ }