@fabrika/builder 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (112) hide show
  1. package/BUILD-CONTRACT.md +41 -0
  2. package/LICENSE +21 -0
  3. package/README.md +100 -0
  4. package/cli/src/bin.ts +66 -0
  5. package/cli/src/build-status.ts +109 -0
  6. package/cli/src/catalog.ts +42 -0
  7. package/cli/src/commands/advance.ts +73 -0
  8. package/cli/src/commands/build.ts +215 -0
  9. package/cli/src/commands/ensure-daemon.ts +25 -0
  10. package/cli/src/commands/gate.ts +165 -0
  11. package/cli/src/commands/induce.ts +61 -0
  12. package/cli/src/commands/init.ts +91 -0
  13. package/cli/src/commands/next.ts +31 -0
  14. package/cli/src/config.ts +47 -0
  15. package/cli/src/context.ts +28 -0
  16. package/cli/src/daemon-client.ts +280 -0
  17. package/cli/src/daemon-lifecycle.ts +171 -0
  18. package/cli/src/derive.ts +109 -0
  19. package/cli/src/index.ts +38 -0
  20. package/cli/src/induce.ts +151 -0
  21. package/cli/src/inject-skills.ts +313 -0
  22. package/cli/src/issues.ts +75 -0
  23. package/cli/src/layout.ts +124 -0
  24. package/cli/src/packet.ts +108 -0
  25. package/cli/src/phase-state.ts +19 -0
  26. package/cli/src/phases.ts +131 -0
  27. package/cli/src/skeletons.ts +104 -0
  28. package/daemon/src/bin.ts +70 -0
  29. package/daemon/src/build-stream.ts +38 -0
  30. package/daemon/src/handlers.ts +472 -0
  31. package/daemon/src/index.ts +123 -0
  32. package/daemon/src/paths.ts +30 -0
  33. package/daemon/src/phases.ts +221 -0
  34. package/daemon/src/static.ts +72 -0
  35. package/decisions/README.md +133 -0
  36. package/decisions/patterns/app-deploy.md +55 -0
  37. package/decisions/patterns/auth-password-kv.md +45 -0
  38. package/decisions/patterns/count-store.md +44 -0
  39. package/decisions/patterns/di-container.md +47 -0
  40. package/decisions/patterns/feature-architecture.md +48 -0
  41. package/decisions/patterns/lib-publish.md +46 -0
  42. package/decisions/patterns/monorepo.md +47 -0
  43. package/decisions/patterns/named-seeds.md +45 -0
  44. package/decisions/patterns/observability.md +45 -0
  45. package/decisions/patterns/single-worker-spa.md +74 -0
  46. package/decisions/patterns/structured-logging.md +49 -0
  47. package/decisions/patterns/typed-worker-rpc.md +87 -0
  48. package/decisions/patterns/vozka-app-deploy.md +74 -0
  49. package/decisions/patterns/write-split.md +47 -0
  50. package/decisions/technologies/analytics-engine.md +44 -0
  51. package/decisions/technologies/bindx.md +55 -0
  52. package/decisions/technologies/bun.md +51 -0
  53. package/decisions/technologies/buzola.md +80 -0
  54. package/decisions/technologies/cloudflare-access.md +50 -0
  55. package/decisions/technologies/cloudflare-vite-plugin.md +43 -0
  56. package/decisions/technologies/cloudflare.md +50 -0
  57. package/decisions/technologies/contember.md +65 -0
  58. package/decisions/technologies/cron-triggers.md +46 -0
  59. package/decisions/technologies/d1.md +51 -0
  60. package/decisions/technologies/durable-objects.md +44 -0
  61. package/decisions/technologies/kv.md +42 -0
  62. package/decisions/technologies/lopata.md +55 -0
  63. package/decisions/technologies/nuasite.md +50 -0
  64. package/decisions/technologies/oblaka.md +48 -0
  65. package/decisions/technologies/opice.md +40 -0
  66. package/decisions/technologies/pletivo.md +53 -0
  67. package/decisions/technologies/postgres.md +47 -0
  68. package/decisions/technologies/preact.md +44 -0
  69. package/decisions/technologies/propustka.md +137 -0
  70. package/decisions/technologies/queue.md +55 -0
  71. package/decisions/technologies/r2.md +41 -0
  72. package/decisions/technologies/react-router.md +47 -0
  73. package/decisions/technologies/react.md +37 -0
  74. package/decisions/technologies/roj.md +51 -0
  75. package/decisions/technologies/sentry.md +49 -0
  76. package/decisions/technologies/tailwind.md +45 -0
  77. package/decisions/technologies/vite.md +47 -0
  78. package/decisions/technologies/vozka.md +122 -0
  79. package/decisions/technologies/workers-ai.md +59 -0
  80. package/decisions/technologies/wrangler.md +41 -0
  81. package/decisions/tooling/deptective.md +45 -0
  82. package/decisions/tooling/edvabe.md +45 -0
  83. package/index.ts +2 -0
  84. package/package.json +51 -0
  85. package/skill/SKILL.md +76 -0
  86. package/skill/agents/build-orchestrator.md +170 -0
  87. package/skill/agents/flows-author.md +65 -0
  88. package/skill/agents/implementer.md +37 -0
  89. package/skill/agents/openai.yaml +4 -0
  90. package/skill/agents/opice-skeleton-author.md +68 -0
  91. package/skill/gates/brief.jsx +20 -0
  92. package/skill/gates/build.jsx +20 -0
  93. package/skill/gates/design.jsx +22 -0
  94. package/skill/gates/flows.jsx +14 -0
  95. package/skill/gates/review-grid.jsx +32 -0
  96. package/skill/gates/tech.jsx +17 -0
  97. package/skill/gates/vision.jsx +27 -0
  98. package/stacks/README.md +44 -0
  99. package/stacks/cf-fat.md +64 -0
  100. package/stacks/cf-internal-app.md +125 -0
  101. package/stacks/cf-lightweight.md +54 -0
  102. package/stacks/contember-app.md +53 -0
  103. package/stacks/content-site.md +45 -0
  104. package/ui/dist/assets/annotations-runtime.js +1 -0
  105. package/ui/dist/assets/client.css +2 -0
  106. package/ui/dist/assets/client.fhjppkvr.js +214 -0
  107. package/ui/dist/assets/components.js +63 -0
  108. package/ui/dist/assets/preact-compat.js +1 -0
  109. package/ui/dist/assets/react-jsx-dev-runtime.js +3 -0
  110. package/ui/dist/assets/react-jsx-runtime.js +3 -0
  111. package/ui/dist/assets/runtime.js +1 -0
  112. package/ui/dist/index.html +36 -0
@@ -0,0 +1,25 @@
1
+ import { BASE_URL } from '../config'
2
+ import { health } from '../daemon-client'
3
+ import { ensureDaemon } from '../daemon-lifecycle'
4
+
5
+ export async function cmdEnsureDaemon(_argv: string[]): Promise<number> {
6
+ const up = await ensureDaemon()
7
+ if (!up) {
8
+ console.log(JSON.stringify({ ok: false, running: false, baseUrl: BASE_URL }))
9
+ return 1
10
+ }
11
+ const info = await health()
12
+ console.log(
13
+ JSON.stringify({
14
+ ok: true,
15
+ running: true,
16
+ baseUrl: BASE_URL,
17
+ version: info?.version ?? null,
18
+ pid: info?.pid ?? null,
19
+ dataDir: info?.dataDir ?? null,
20
+ sessionsDir: info?.sessionsDir ?? null,
21
+ sessions: info?.sessions ?? null,
22
+ }),
23
+ )
24
+ return 0
25
+ }
@@ -0,0 +1,165 @@
1
+ import { existsSync, statSync } from 'fs'
2
+ import { extname, resolve } from 'path'
3
+ import { parseArgs } from 'util'
4
+ import { BASE_URL, TIMEOUT_MS } from '../config'
5
+ import { resolveContext } from '../context'
6
+ import { isDaemonRunning, peekFeedback, pushPlan, waitForFeedback } from '../daemon-client'
7
+ import { ensureDaemon, openBrowser } from '../daemon-lifecycle'
8
+
9
+ export async function cmdGate(argv: string[]): Promise<number> {
10
+ const sub = argv[0]
11
+ const rest = argv.slice(1)
12
+ if (sub === 'open') return gateOpen(rest)
13
+ if (sub === 'wait') return gateWait(rest)
14
+ console.error(`[fabrika] unknown gate subcommand: ${sub ?? '(none)'}. Use \`gate open\` or \`gate wait\`.`)
15
+ return 1
16
+ }
17
+
18
+ async function gateOpen(argv: string[]): Promise<number> {
19
+ const { values, positionals } = parseArgs({
20
+ args: argv,
21
+ options: {
22
+ view: { type: 'string' },
23
+ dir: { type: 'string' },
24
+ label: { type: 'string' },
25
+ session: { type: 'string' },
26
+ root: { type: 'string' },
27
+ },
28
+ allowPositionals: true,
29
+ })
30
+
31
+ const name = positionals[0]
32
+ if (!name) {
33
+ console.error('[fabrika] gate open requires a <name>. Usage: fabrika builder gate open <name> --view <file.jsx>')
34
+ return 1
35
+ }
36
+ if (!values.view && !values.dir) {
37
+ console.error('[fabrika] gate open requires exactly one of --view <file.jsx> or --dir <jsxDir>.')
38
+ return 1
39
+ }
40
+ if (values.view && values.dir) {
41
+ console.error('[fabrika] gate open accepts either --view or --dir, not both.')
42
+ return 1
43
+ }
44
+
45
+ const ctx = resolveContext({ dir: values.root, session: values.session })
46
+
47
+ let source: { view: string } | { directory: string }
48
+ if (values.dir) {
49
+ const directory = resolve(values.dir)
50
+ if (!existsSync(directory) || !statSync(directory).isDirectory()) {
51
+ console.error(`[fabrika] --dir not a directory: ${directory}`)
52
+ return 1
53
+ }
54
+ source = { directory }
55
+ } else {
56
+ const viewPath = resolve(values.view!)
57
+ if (!existsSync(viewPath)) {
58
+ console.error(`[fabrika] --view file not found: ${viewPath}`)
59
+ return 1
60
+ }
61
+ if (!statSync(viewPath).isFile()) {
62
+ console.error(`[fabrika] --view must be a file; use --dir for a directory: ${viewPath}`)
63
+ return 1
64
+ }
65
+ if (extname(viewPath).toLowerCase() !== '.jsx') {
66
+ console.error(`[fabrika] --view must point to a .jsx file: ${viewPath}`)
67
+ return 1
68
+ }
69
+ source = { view: viewPath }
70
+ }
71
+
72
+ const label = values.label ?? name
73
+ const browserUrl = `${BASE_URL}/s/${ctx.sessionId}`
74
+
75
+ const up = await ensureDaemon({ quiet: true })
76
+ if (!up) {
77
+ console.error(`[fabrika] daemon unavailable; cannot push gate "${name}".`)
78
+ console.log(
79
+ JSON.stringify({
80
+ ok: false,
81
+ reachable: false,
82
+ gate: name,
83
+ ...source,
84
+ browserUrl,
85
+ hint: 'Start the daemon (fabrika builder ensure-daemon), then re-run gate open.',
86
+ }),
87
+ )
88
+ return 0 // soft-fail so the loop can proceed offline / be tested
89
+ }
90
+
91
+ const result = await pushPlan(ctx.sessionId, {
92
+ ...source,
93
+ projectRoot: ctx.repoRoot,
94
+ label,
95
+ gate: name,
96
+ })
97
+
98
+ if (!result.reachable || !result.ok) {
99
+ console.error(`[fabrika] gate push failed: ${result.error ?? 'compile error'}`)
100
+ if (result.errors) {
101
+ for (const [file, err] of Object.entries(result.errors)) {
102
+ console.error(` ${file}: ${err}`)
103
+ }
104
+ }
105
+ return 1
106
+ }
107
+
108
+ if (result.isNew && result.browserUrl) {
109
+ openBrowser(result.browserUrl)
110
+ }
111
+
112
+ console.log(
113
+ JSON.stringify({
114
+ ok: true,
115
+ browserUrl: result.browserUrl ?? browserUrl,
116
+ revision: result.revision,
117
+ gate: name,
118
+ sessionId: ctx.sessionId,
119
+ canvasFiles: result.canvasFiles,
120
+ }),
121
+ )
122
+ return 0
123
+ }
124
+
125
+ async function gateWait(argv: string[]): Promise<number> {
126
+ const { values } = parseArgs({
127
+ args: argv,
128
+ options: {
129
+ gate: { type: 'string' },
130
+ timeout: { type: 'string' },
131
+ session: { type: 'string' },
132
+ root: { type: 'string' },
133
+ },
134
+ allowPositionals: true,
135
+ })
136
+
137
+ const ctx = resolveContext({ dir: values.root, session: values.session })
138
+ const timeoutMs = values.timeout ? parseInt(values.timeout, 10) : TIMEOUT_MS
139
+
140
+ const up = await ensureDaemon({ quiet: true })
141
+ if (!up) {
142
+ console.error('[fabrika] daemon unavailable; cannot wait for gate feedback.')
143
+ return 1
144
+ }
145
+
146
+ const pending = await peekFeedback(ctx.sessionId)
147
+ if (pending !== null) {
148
+ process.stdout.write(pending)
149
+ return 0
150
+ }
151
+
152
+ try {
153
+ const feedback = await waitForFeedback(ctx.sessionId, timeoutMs)
154
+ process.stdout.write(feedback)
155
+ return 0
156
+ } catch (error: unknown) {
157
+ const reason = error instanceof Error ? error.message : String(error)
158
+ if (reason === 'timeout') {
159
+ console.error('[fabrika] timeout waiting for gate feedback.')
160
+ } else {
161
+ console.error(`[fabrika] gate wait failed: ${reason}`)
162
+ }
163
+ return 1
164
+ }
165
+ }
@@ -0,0 +1,61 @@
1
+ import { existsSync, mkdirSync, writeFileSync } from 'fs'
2
+ import { join } from 'path'
3
+ import { parseArgs } from 'util'
4
+ import { resolveStackPointer } from '../catalog'
5
+ import { resolveContext } from '../context'
6
+ import { induceIssues, renderIssue } from '../induce'
7
+ import { readSkeletons } from '../skeletons'
8
+
9
+ export async function cmdInduce(argv: string[]): Promise<number> {
10
+ const { values } = parseArgs({
11
+ args: argv,
12
+ options: {
13
+ dir: { type: 'string' },
14
+ session: { type: 'string' },
15
+ force: { type: 'boolean', default: false },
16
+ },
17
+ allowPositionals: true,
18
+ })
19
+
20
+ const ctx = resolveContext({ dir: values.dir, session: values.session })
21
+ if (!ctx.initialized) {
22
+ console.error(`[fabrika] no .fabrika/ found at or above ${ctx.repoRoot}. Run \`fabrika builder init\` first.`)
23
+ return 1
24
+ }
25
+
26
+ const testsBrowserDir = join(ctx.repoRoot, 'tests', 'browser')
27
+ const skeletons = readSkeletons(testsBrowserDir)
28
+ if (skeletons.length === 0) {
29
+ console.error(
30
+ `[fabrika] no opice skeletons in ${testsBrowserDir}. `
31
+ + `Author them in phase 6 (skeletons) first.`,
32
+ )
33
+ return 1
34
+ }
35
+
36
+ const issues = induceIssues(skeletons, { stack: resolveStackPointer(ctx.profile) ?? undefined })
37
+ const issuesDir = join(ctx.fabrikaDir, 'issues')
38
+ mkdirSync(issuesDir, { recursive: true })
39
+
40
+ let written = 0
41
+ let skipped = 0
42
+ for (const issue of issues) {
43
+ const path = join(issuesDir, `${issue.id}.md`)
44
+ if (existsSync(path) && !values.force) {
45
+ skipped++
46
+ continue
47
+ }
48
+ writeFileSync(path, renderIssue(issue))
49
+ written++
50
+ }
51
+
52
+ const foundation = issues.filter((i) => i.kind === 'foundation').length
53
+ const feature = issues.filter((i) => i.kind === 'feature').length
54
+ console.log(
55
+ `[fabrika] induced ${issues.length} issue(s) from ${skeletons.length} skeleton(s): `
56
+ + `${foundation} foundation + ${feature} feature.`,
57
+ )
58
+ console.log(`[fabrika] wrote ${written}, skipped ${skipped} existing (use --force to overwrite).`)
59
+ console.log(`[fabrika] → .fabrika/issues/ · next: fabrika builder advance && fabrika builder next (build)`)
60
+ return 0
61
+ }
@@ -0,0 +1,91 @@
1
+ import { rmSync } from 'fs'
2
+ import { join } from 'path'
3
+ import { parseArgs } from 'util'
4
+ import { BASE_URL, SESSIONS_DIR } from '../config'
5
+ import { ensureDaemon, openBrowser } from '../daemon-lifecycle'
6
+ import { type AgentHost, injectAgentSupport, parseAgentHosts } from '../inject-skills'
7
+ import { scaffold } from '../layout'
8
+
9
+ async function resetStaleSession(sessionId: string): Promise<void> {
10
+ try {
11
+ await fetch(`${BASE_URL}/api/session/${encodeURIComponent(sessionId)}/reset`, { method: 'POST' })
12
+ } catch {
13
+ }
14
+ try {
15
+ rmSync(join(SESSIONS_DIR, sessionId), { recursive: true, force: true })
16
+ } catch {
17
+ }
18
+ }
19
+
20
+ export async function cmdInit(argv: string[]): Promise<number> {
21
+ const { values, positionals } = parseArgs({
22
+ args: argv,
23
+ options: {
24
+ profile: { type: 'string', default: 'cf-lightweight' },
25
+ session: { type: 'string' },
26
+ agent: { type: 'string', default: 'all' },
27
+ 'no-daemon': { type: 'boolean', default: false },
28
+ 'no-open': { type: 'boolean', default: false },
29
+ },
30
+ allowPositionals: true,
31
+ })
32
+
33
+ let hosts: AgentHost[]
34
+ try {
35
+ hosts = parseAgentHosts(values.agent)
36
+ } catch (error: unknown) {
37
+ console.error(`[fabrika] ${error instanceof Error ? error.message : String(error)}`)
38
+ return 1
39
+ }
40
+ const dir = positionals[0] ?? process.cwd()
41
+ const result = scaffold(dir, { profile: values.profile, sessionId: values.session })
42
+ const support = injectAgentSupport(result.repoRoot, { hosts })
43
+
44
+ if (result.alreadyInitialized) {
45
+ console.error(`[fabrika] .fabrika/ already initialized at ${result.fabrikaDir} (idempotent re-run)`)
46
+ } else {
47
+ console.error(`[fabrika] scaffolded .fabrika/ at ${result.fabrikaDir}`)
48
+ }
49
+ if (result.createdDirs.length) {
50
+ console.error(`[fabrika] created ${result.createdDirs.length} dir(s)`)
51
+ }
52
+ if (support.sourceDir) {
53
+ console.error(
54
+ `[fabrika] agent support (${support.hosts.join('+')}): `
55
+ + `${support.written.length} installed, ${support.updated.length} updated, `
56
+ + `${support.skipped.length} current, ${support.preserved.length} user-edited preserved`,
57
+ )
58
+ } else {
59
+ console.error(`[fabrika] agent support: source dir not found, skipped installation`)
60
+ }
61
+
62
+ const browserUrl = `${BASE_URL}/s/${result.config.sessionId}`
63
+ if (!values['no-daemon']) {
64
+ const up = await ensureDaemon({ quiet: true })
65
+ if (!result.alreadyInitialized) {
66
+ await resetStaleSession(result.config.sessionId)
67
+ console.error(`[fabrika] cleared any stale session for ${result.config.sessionId}`)
68
+ }
69
+ if (up && !values['no-open']) {
70
+ openBrowser(browserUrl)
71
+ } else if (!up) {
72
+ console.error(`[fabrika] daemon not started (offline). Dashboard: ${browserUrl}`)
73
+ }
74
+ } else if (!result.alreadyInitialized) {
75
+ await resetStaleSession(result.config.sessionId)
76
+ }
77
+
78
+ console.log(
79
+ JSON.stringify({
80
+ ok: true,
81
+ repoRoot: result.repoRoot,
82
+ fabrikaDir: result.fabrikaDir,
83
+ sessionId: result.config.sessionId,
84
+ profile: result.config.profile,
85
+ agents: support.hosts,
86
+ browserUrl,
87
+ createdConfig: result.createdConfig,
88
+ }),
89
+ )
90
+ return 0
91
+ }
@@ -0,0 +1,31 @@
1
+ import { parseArgs } from 'util'
2
+ import { resolveContext } from '../context'
3
+ import { packetJson, renderPacket } from '../packet'
4
+ import { resolvePhaseState } from '../phase-state'
5
+
6
+ export async function cmdNext(argv: string[]): Promise<number> {
7
+ const { values } = parseArgs({
8
+ args: argv,
9
+ options: {
10
+ json: { type: 'boolean', default: false },
11
+ dir: { type: 'string' },
12
+ session: { type: 'string' },
13
+ },
14
+ allowPositionals: true,
15
+ })
16
+
17
+ const ctx = resolveContext({ dir: values.dir, session: values.session })
18
+ if (!ctx.initialized) {
19
+ console.error(`[fabrika] no .fabrika/ found at or above ${ctx.repoRoot}. Run \`fabrika builder init\` first.`)
20
+ return 1
21
+ }
22
+
23
+ const { state, source } = await resolvePhaseState(ctx.repoRoot, ctx.fabrikaDir, ctx.sessionId)
24
+
25
+ if (values.json) {
26
+ console.log(JSON.stringify({ ...packetJson(state, { profile: ctx.profile }), source }))
27
+ } else {
28
+ process.stdout.write(renderPacket(state, { profile: ctx.profile }))
29
+ }
30
+ return 0
31
+ }
@@ -0,0 +1,47 @@
1
+ import { createHash } from 'crypto'
2
+ import { existsSync } from 'fs'
3
+ import { homedir, tmpdir } from 'os'
4
+ import { join, resolve } from 'path'
5
+
6
+ export const DAEMON_PORT = parseInt(process.env['FABRIKA_PORT'] || '19410', 10)
7
+ export const BASE_URL = `http://localhost:${DAEMON_PORT}`
8
+ export const WS_URL = `ws://localhost:${DAEMON_PORT}`
9
+
10
+ export const TIMEOUT_MS = parseInt(process.env['FABRIKA_TIMEOUT'] || String(60 * 60 * 1000), 10)
11
+
12
+ export const LEGACY_DATA_DIR = join(homedir(), '.claude', 'fabrika')
13
+ export const NEUTRAL_DATA_DIR = join(homedir(), '.fabrika')
14
+ export const DATA_DIR = resolveDataDir()
15
+ export const SESSIONS_DIR = join(DATA_DIR, 'sessions')
16
+ export const TEMP_DIR = join(tmpdir(), 'fabrika')
17
+ export const PID_FILE = join(TEMP_DIR, 'daemon.pid')
18
+
19
+ export const FABRIKA_DIRNAME = '.fabrika'
20
+
21
+ export function resolveDataDir(opts: {
22
+ home?: string
23
+ explicit?: string
24
+ exists?: (path: string) => boolean
25
+ } = {}): string {
26
+ const home = opts.home ?? homedir()
27
+ const explicit = opts.explicit ?? process.env['FABRIKA_DATA_DIR']
28
+ if (explicit) return resolve(explicit)
29
+ const neutral = join(home, '.fabrika')
30
+ const legacy = join(home, '.claude', 'fabrika')
31
+ const exists = opts.exists ?? existsSync
32
+ if (exists(neutral)) return neutral
33
+ if (exists(legacy)) return legacy
34
+ return neutral
35
+ }
36
+
37
+ export function getSessionId(explicit?: string, recorded?: string, repoRoot?: string): string {
38
+ if (explicit) return explicit
39
+ if (process.env['FABRIKA_SESSION_ID']) return process.env['FABRIKA_SESSION_ID']
40
+ if (recorded) return recorded
41
+ return hashRepoPath(repoRoot ?? process.cwd())
42
+ }
43
+
44
+ export function hashRepoPath(repoRoot: string): string {
45
+ const abs = resolve(repoRoot)
46
+ return 's_' + createHash('sha256').update(abs).digest('hex').slice(0, 16)
47
+ }
@@ -0,0 +1,28 @@
1
+ import { resolve } from 'path'
2
+ import { getSessionId } from './config'
3
+ import { fabrikaDir, findRepoRoot, readConfig } from './layout'
4
+
5
+ export interface CommandContext {
6
+ repoRoot: string
7
+ fabrikaDir: string
8
+ sessionId: string
9
+ profile: string
10
+ initialized: boolean
11
+ }
12
+
13
+ export function resolveContext(opts: { dir?: string; session?: string } = {}): CommandContext {
14
+ const repoRoot = opts.dir
15
+ ? resolve(opts.dir)
16
+ : findRepoRoot(process.cwd()) ?? process.cwd()
17
+
18
+ const config = readConfig(repoRoot)
19
+ const sessionId = getSessionId(opts.session, config?.sessionId, repoRoot)
20
+
21
+ return {
22
+ repoRoot,
23
+ fabrikaDir: fabrikaDir(repoRoot),
24
+ sessionId,
25
+ profile: config?.profile ?? 'cf-lightweight',
26
+ initialized: config !== null,
27
+ }
28
+ }