@frontera-sdk/cli 1.50.12 → 1.50.13

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.
@@ -0,0 +1,90 @@
1
+ import type { ForgeArtifactTouchDetails, ForgeProject } from '@frontera-sdk/forge-contracts'
2
+
3
+ import { PlatformApi } from '../api/platform-api'
4
+ import { agentDraftChanges } from '../commands/agent/draft-changes'
5
+ import { resolveAgentRef, type AgentRow } from '../commands/agent/resolve'
6
+ import { resolveCredential } from '../config'
7
+ import type { TouchInput } from './touches'
8
+
9
+ /**
10
+ * What a touch changed, in more detail than the command line says.
11
+ *
12
+ * `frontera agent apply underwriter` tells the pane WHICH agent; it does not
13
+ * say which section. The draft the command staged does — it differs from
14
+ * live in exactly the sections the session edited — so after the command
15
+ * succeeds the daemon asks, the same way `frontera agent diff` would, and
16
+ * records the answer on the touch. The pane opens the page on that section.
17
+ *
18
+ * In-process, with the project's own profile: the daemon IS the CLI, so it
19
+ * resolves the credential the way any command run in that folder would and
20
+ * calls the platform directly. Nothing is spawned.
21
+ *
22
+ * Best-effort by design. A failure here (no profile, a key that cannot read
23
+ * drafts, the network) leaves the touch as it was — the pane still opens the
24
+ * right page, just at the top — and never touches the turn.
25
+ */
26
+ export type TouchEnricher = (touch: TouchInput, project: ForgeProject) => Promise<ForgeArtifactTouchDetails | null>
27
+
28
+
29
+ const TIMEOUT_MS = 15_000
30
+
31
+ function withTimeout<T>(promise: Promise<T>, ms: number): Promise<T> {
32
+ return new Promise<T>((resolve, reject) => {
33
+ const timer = setTimeout(() => reject(new Error(`timed out after ${ms}ms`)), ms)
34
+ timer.unref?.()
35
+ promise.then(
36
+ (value) => {
37
+ clearTimeout(timer)
38
+ resolve(value)
39
+ },
40
+ (err) => {
41
+ clearTimeout(timer)
42
+ reject(err)
43
+ },
44
+ )
45
+ })
46
+ }
47
+
48
+ /**
49
+ * What this touch changed on its own. The draft diff is cumulative — after a
50
+ * prompt edit and then a knowledge binding it lists both — so the pane would
51
+ * point at the prompt twice. Subtracting what the previous touch on the same
52
+ * agent already reported leaves the new part. Same agent means same ref, or
53
+ * same resolved name: the CLI addresses by slug and MCP by uuid.
54
+ */
55
+ export function touchDelta(
56
+ changed: readonly string[],
57
+ previous: { changed: readonly string[] } | null | undefined,
58
+ ): string[] {
59
+ if (!previous) return [...changed]
60
+ const before = new Set(previous.changed)
61
+ return changed.filter((k) => !before.has(k))
62
+ }
63
+
64
+ export const enrichTouch: TouchEnricher = async (touch, project) => {
65
+ // Any change to an agent that did not make it live may sit on the draft —
66
+ // from the CLI (`apply`, `skill-attach`) or from MCP (`update_agent`,
67
+ // `set_prompt`, `bind_skill`, …). No draft is a normal answer, not an error.
68
+ const { ref } = touch
69
+ const { profile } = project
70
+ if (touch.kind !== 'agent' || !ref || touch.live || !profile) return null
71
+
72
+ const run = async (): Promise<ForgeArtifactTouchDetails | null> => {
73
+ const credential = await resolveCredential({ profile }, { cwd: project.root })
74
+ // An organization key names no workspace, and agents live in one. The
75
+ // managed folder IS a workspace's folder, so the project knows which —
76
+ // the same thing `--workspace` would say on the command line.
77
+ const workspaceId = credential.workspaceId ?? project.workspaceId ?? undefined
78
+ const api = new PlatformApi(credential.apiUrl, credential.token, workspaceId)
79
+ const id = await resolveAgentRef(api, ref)
80
+ const [outcome, row] = await Promise.all([
81
+ agentDraftChanges(api, id),
82
+ // The name, for the pane header: an MCP tool addresses the agent by
83
+ // uuid, which is nothing a person recognises.
84
+ (api.agent(id) as Promise<AgentRow>).catch(() => null),
85
+ ])
86
+ const label = row?.name ?? row?.agentId
87
+ return { changed: outcome.hasDraft ? outcome.changed : [], ...(label ? { label } : {}) }
88
+ }
89
+ return withTimeout(run(), TIMEOUT_MS)
90
+ }
@@ -0,0 +1,412 @@
1
+ import type { ForgeArtifactKind } from '@frontera-sdk/forge-contracts'
2
+ import type { UIMessageChunk } from 'ai'
3
+
4
+ /**
5
+ * Which platform artifacts a session changed, read off the `frontera`
6
+ * commands and platform MCP tools the host ran.
7
+ *
8
+ * Recorded live, as the run streams, into their own table: the pane follows
9
+ * the run as it happens, and the store answers a reload without re-reading
10
+ * every message. (The persisted message keeps tool parts on the normal path;
11
+ * only the malformed-stream fallback drops to text.)
12
+ *
13
+ * The signal is the COMMAND, not the files. The workspace folder's layout is
14
+ * whatever the session invented (`design/`, `ingest/`, `org-admin/` …), so a
15
+ * changed path says nothing reliable about which agent or object type it is.
16
+ * `frontera agent apply underwriter` says exactly: the noun, the verb, the
17
+ * slug. And the tool result says whether it worked.
18
+ *
19
+ * Two rules keep the preview pane honest:
20
+ *
21
+ * - Only a command that did not fail records a touch. A `tool-output-error`
22
+ * discards what the input promised.
23
+ * - Only MUTATING verbs record one. The pane should not jump because the
24
+ * session ran `frontera agent list` to look around.
25
+ */
26
+
27
+ /** Runtime list; `ForgeArtifactKind` in forge-contracts is its type. */
28
+ export const FORGE_ARTIFACT_KINDS = [
29
+ 'agent',
30
+ 'automation',
31
+ 'app',
32
+ 'blueprint',
33
+ 'action',
34
+ 'skill',
35
+ 'source',
36
+ 'dataset',
37
+ 'knowledge',
38
+ ] as const satisfies readonly ForgeArtifactKind[]
39
+
40
+ /**
41
+ * Verbs that change the artifact. Everything else on a noun — `list`, `get`,
42
+ * `diff`, `status`, `versions`, `catalog`, `query`, `preview`, `rows`,
43
+ * `search`, `runs`, `bindings` — is a read and is deliberately absent.
44
+ */
45
+ export const TOUCHING_VERBS: Record<ForgeArtifactKind, readonly string[]> = {
46
+ agent: ['apply', 'create', 'publish', 'revert', 'discard', 'skill-attach', 'skill-detach'],
47
+ automation: ['deploy', 'promote', 'enable', 'disable', 'init', 'bind', 'unbind', 'run'],
48
+ app: ['deploy', 'promote', 'save', 'add'],
49
+ blueprint: ['apply', 'publish', 'create', 'update', 'delete', 'rename', 'rollback', 'bind', 'grant', 'adopt', 'editable'],
50
+ action: ['prepare', 'deploy', 'grant', 'review'],
51
+ skill: ['push', 'delete'],
52
+ source: ['create', 'update', 'revise', 'disable'],
53
+ dataset: ['create', 'upload', 'test-source'],
54
+ knowledge: ['create', 'attach', 'detach', 'upload', 'retry', 'delete'],
55
+ }
56
+
57
+ /** Verbs after which the artifact is LIVE, not merely changed. */
58
+ const LIVE_VERBS: Partial<Record<ForgeArtifactKind, readonly string[]>> = {
59
+ agent: ['publish'],
60
+ automation: ['promote', 'enable'],
61
+ app: ['promote'],
62
+ blueprint: ['publish'],
63
+ }
64
+
65
+ /**
66
+ * Nouns the CLI registers under another name. `function` is what `--help`
67
+ * prints and what a host types; `automation` is the alias it still accepts.
68
+ */
69
+ export const NOUN_ALIASES: Record<string, ForgeArtifactKind> = { function: 'automation' }
70
+
71
+ /**
72
+ * Nouns whose verbs change an AGENT's draft: the touch belongs to the agent
73
+ * the positional names, not to a "capability" or a "pack" page. `frontera
74
+ * capability grant underwriter blueprint:read` stages the same section a
75
+ * later `agent apply` would report as `capabilities`.
76
+ */
77
+ export const AGENT_PROXY_NOUNS: Record<string, { verbs: readonly string[]; agentAt: number }> = {
78
+ capability: { verbs: ['grant', 'revoke', 'bind', 'unbind'], agentAt: 0 },
79
+ pack: { verbs: ['apply', 'detach'], agentAt: 1 },
80
+ }
81
+
82
+ /**
83
+ * Verbs whose positional is a FILE, not the artifact's name. `frontera
84
+ * function deploy src/nightly.ts` names a source file; the slug comes from
85
+ * inside it. Recording the path as the ref would build a page for a path.
86
+ */
87
+ const FILE_POSITIONAL_VERBS: Partial<Record<ForgeArtifactKind, readonly string[]>> = {
88
+ automation: ['deploy', 'init'],
89
+ skill: ['push'],
90
+ blueprint: ['create'],
91
+ // `app deploy` names nothing (the folder is the app); `app promote` names
92
+ // a VERSION. The app itself is the project's, which the pane knows.
93
+ app: ['deploy', 'promote', 'save', 'add'],
94
+ }
95
+
96
+ /**
97
+ * Blueprint verbs whose FIRST positional is the definition kind
98
+ * (`object-type`, `link-type`, `metric`, …) and whose second is the name:
99
+ * `frontera blueprint update object-type Loan`. The ref is the name.
100
+ */
101
+ const BLUEPRINT_KINDED_VERBS = new Set(['update', 'delete', 'rename'])
102
+
103
+ /**
104
+ * Flags that take no value. Any other `--flag` is assumed to consume the next
105
+ * token, so `--profile kata underwriter` yields the ref `underwriter` and not
106
+ * `kata`. Unknown boolean flags would swallow one token — a wrong ref, never a
107
+ * wrong noun — and the parser tests pin the ones the CLI actually has.
108
+ */
109
+ export const BOOLEAN_FLAGS = new Set([
110
+ // Global.
111
+ 'json', 'quiet', 'yes', 'help', 'force',
112
+ // Every `'boolean'` FlagSpec a command declares (`grep "'boolean'" src/commands`).
113
+ 'accept-contract-change', 'accept-orphaned-knowledge', 'dry-run', 'end-user', 'no-activate',
114
+ 'no-components', 'no-git', 'no-input', 'no-install', 'no-open', 'no-promote', 'no-wait',
115
+ 'remove-apps', 'token-stdin', 'prune', 'dev', 'revoke', 'draft', 'check',
116
+ ])
117
+
118
+ /**
119
+ * A shell redirection, attached (`>out.log`, `2>&1`, `>/dev/null`) or as an
120
+ * operator on its own (`>`, `>>`, `<`, `2>`, `&>`) whose target is the next
121
+ * token. Neither is an argument to `frontera`.
122
+ */
123
+ const REDIRECTION = /^(\d*|&)?(>>?|<)(&\d+)?(.*)$/
124
+
125
+ export interface ParsedFronteraCommand {
126
+ kind: ForgeArtifactKind
127
+ verb: string
128
+ ref: string | null
129
+ live: boolean
130
+ }
131
+
132
+ /**
133
+ * The platform's MCP server, which the daemon injects as `frontera`. A host
134
+ * that has it reaches for `update_agent` as readily as for the CLI, and the
135
+ * pane must follow either — the earlier session that used the CLI opened the
136
+ * pane, the later one that used MCP did not, for the same edit.
137
+ *
138
+ * Tool names are the catalogue's (`copilot-tool-catalog.ts` in the service):
139
+ * `mcp__frontera__<tool>`. Reads never touch; everything else touches the
140
+ * artifact its domain names, keyed by the id the tool takes.
141
+ */
142
+ const MCP_PREFIX = 'mcp__frontera__'
143
+
144
+ /**
145
+ * Tools that change nothing: a `list_`/`get_` segment anywhere in the name
146
+ * (`list_agents`, `data_list_sources`, `get_draft_diff`), or one of the named
147
+ * reads that call themselves something else. `touches-mcp-catalog.test.ts`
148
+ * holds this to the service's own risk classification.
149
+ */
150
+ const MCP_READ_SEGMENT = /(^|_)(list|get)_/
151
+ const MCP_READ_TOOLS = new Set([
152
+ 'navigate', 'validate_case', 'test_case', 'test_agent', 'test_channel', 'verify_mcp_server',
153
+ 'blueprint_aggregate', 'blueprint_query',
154
+ ])
155
+
156
+ /** Tools whose success makes the artifact LIVE. */
157
+ const MCP_LIVE_TOOLS = new Set(['publish_agent', 'publish_case', 'promote_platform_app_version'])
158
+
159
+ /** Tools that remove the artifact: nothing left to preview. */
160
+ const MCP_GONE_TOOLS = new Set(['delete_agent'])
161
+
162
+ /**
163
+ * The artifact a tool changes, from its name. Anything on an agent — its
164
+ * prompts, skills, knowledge, packs, capabilities, MCP servers, channels — is
165
+ * a touch on THAT AGENT: the page the person wants is the agent's.
166
+ */
167
+ function mcpArtifact(tool: string, input: Record<string, unknown>): { kind: ForgeArtifactKind; ref: string | null } | null {
168
+ const str = (key: string) => (typeof input[key] === 'string' && input[key] ? (input[key] as string) : null)
169
+ if (tool.startsWith('blueprint_')) {
170
+ // Most Blueprint tools name the OBJECT TYPE separately from the thing
171
+ // they add to it (`objectTypeApiName` + a property `apiName`); only the
172
+ // object-type tools carry the type as `apiName`.
173
+ const objectType = str('objectTypeApiName') ?? (/_object_type$/.test(tool) ? str('apiName') : null)
174
+ return { kind: 'blueprint', ref: objectType }
175
+ }
176
+ const agentId = str('agentId') ?? str('caseId')
177
+ if (agentId) return { kind: 'agent', ref: agentId }
178
+ if (tool === 'data_create_source') return { kind: 'source', ref: str('displayName') ?? str('name') }
179
+ if (tool === 'data_create_dataset') return { kind: 'dataset', ref: str('apiName') ?? str('name') }
180
+ if (tool === 'promote_platform_app_version') return { kind: 'app', ref: str('app') ?? str('slug') }
181
+ if (/_skill$/.test(tool)) return { kind: 'skill', ref: str('slug') ?? str('skillId') }
182
+ if (/knowledge/.test(tool)) return { kind: 'knowledge', ref: str('knowledgeBaseId') ?? str('name') }
183
+ if (/^create_(work_)?agent$|^duplicate_agent$/.test(tool)) return { kind: 'agent', ref: null }
184
+ return null
185
+ }
186
+
187
+ export function parseMcpTool(toolName: string, input: unknown): ParsedFronteraCommand | null {
188
+ if (!toolName.startsWith(MCP_PREFIX)) return null
189
+ const tool = toolName.slice(MCP_PREFIX.length)
190
+ if (MCP_READ_SEGMENT.test(tool) || MCP_READ_TOOLS.has(tool)) return null
191
+ if (MCP_GONE_TOOLS.has(tool)) return null
192
+ const record = input && typeof input === 'object' && !Array.isArray(input) ? (input as Record<string, unknown>) : {}
193
+ const artifact = mcpArtifact(tool, record)
194
+ if (!artifact) return null
195
+ return { kind: artifact.kind, verb: tool, ref: artifact.ref, live: MCP_LIVE_TOOLS.has(tool) }
196
+ }
197
+
198
+ /** What the tracker hands back for the store to persist. */
199
+ export interface TouchInput extends ParsedFronteraCommand {
200
+ toolCallId: string
201
+ }
202
+
203
+ function isKind(value: string): value is ForgeArtifactKind {
204
+ return (FORGE_ARTIFACT_KINDS as readonly string[]).includes(value)
205
+ }
206
+
207
+ /**
208
+ * Split a shell line into the simple commands a `&&`, `||`, `;` or `|`
209
+ * separates, honouring quotes so a separator inside an argument stays put.
210
+ * Subshells and redirections are not modelled — a command that hides
211
+ * `frontera` behind one is simply not recognised, which fails safe.
212
+ */
213
+ function splitCommands(line: string): string[] {
214
+ const out: string[] = []
215
+ let current = ''
216
+ let quote: '"' | "'" | null = null
217
+ for (let i = 0; i < line.length; i += 1) {
218
+ const ch = line[i]!
219
+ if (quote) {
220
+ current += ch
221
+ if (ch === quote) quote = null
222
+ else if (ch === '\\' && quote === '"' && i + 1 < line.length) current += line[++i]
223
+ continue
224
+ }
225
+ if (ch === '"' || ch === "'") {
226
+ quote = ch
227
+ current += ch
228
+ continue
229
+ }
230
+ if (ch === '\\' && i + 1 < line.length) {
231
+ current += ch + line[++i]
232
+ continue
233
+ }
234
+ const two = line.slice(i, i + 2)
235
+ if (two === '&&' || two === '||') {
236
+ out.push(current)
237
+ current = ''
238
+ i += 1
239
+ continue
240
+ }
241
+ if (ch === ';' || ch === '|' || ch === '\n') {
242
+ out.push(current)
243
+ current = ''
244
+ continue
245
+ }
246
+ current += ch
247
+ }
248
+ out.push(current)
249
+ return out.map((c) => c.trim()).filter((c) => c !== '')
250
+ }
251
+
252
+ /** Tokenize one simple command, dropping the quotes an argument was wrapped in. */
253
+ function tokenize(command: string): string[] {
254
+ const tokens: string[] = []
255
+ let current = ''
256
+ let has = false
257
+ let quote: '"' | "'" | null = null
258
+ for (let i = 0; i < command.length; i += 1) {
259
+ const ch = command[i]!
260
+ if (quote) {
261
+ if (ch === quote) quote = null
262
+ else if (ch === '\\' && quote === '"' && i + 1 < command.length) current += command[++i]
263
+ else current += ch
264
+ continue
265
+ }
266
+ if (ch === '"' || ch === "'") {
267
+ quote = ch
268
+ has = true
269
+ continue
270
+ }
271
+ if (ch === '\\' && i + 1 < command.length) {
272
+ current += command[++i]
273
+ has = true
274
+ continue
275
+ }
276
+ if (ch === ' ' || ch === '\t') {
277
+ if (has) tokens.push(current)
278
+ current = ''
279
+ has = false
280
+ continue
281
+ }
282
+ current += ch
283
+ has = true
284
+ }
285
+ if (has) tokens.push(current)
286
+ return tokens
287
+ }
288
+
289
+ /**
290
+ * The index of the first token AFTER the `frontera` invocation, or -1.
291
+ * Accepts `frontera`, a path ending in `/frontera`, and `bunx`/`npx` in front
292
+ * of either `frontera` or the published package name (with or without a
293
+ * version). Leading `NAME=value` assignments are skipped.
294
+ */
295
+ function invocationEnd(tokens: string[]): number {
296
+ let i = 0
297
+ while (i < tokens.length && /^[A-Za-z_][A-Za-z0-9_]*=/.test(tokens[i]!)) i += 1
298
+ const head = tokens[i]
299
+ if (!head) return -1
300
+ const isFrontera = (t: string) => t === 'frontera' || t.endsWith('/frontera')
301
+ if (isFrontera(head)) return i + 1
302
+ if (head === 'bunx' || head === 'npx') {
303
+ const next = tokens[i + 1]
304
+ if (!next) return -1
305
+ if (isFrontera(next) || next.startsWith('@frontera-sdk/cli')) return i + 2
306
+ }
307
+ return -1
308
+ }
309
+
310
+ /**
311
+ * Parse every `frontera` invocation on a shell line into the touches it
312
+ * would record if it succeeded. Non-`frontera` commands, read verbs and
313
+ * unknown nouns yield nothing.
314
+ */
315
+ export function parseFronteraCommands(line: string): ParsedFronteraCommand[] {
316
+ const out: ParsedFronteraCommand[] = []
317
+ for (const command of splitCommands(line)) {
318
+ const tokens = tokenize(command)
319
+ const start = invocationEnd(tokens)
320
+ if (start < 0) continue
321
+
322
+ // Positionals after the invocation, with flags (and their values) and
323
+ // shell redirections removed.
324
+ const positionals: string[] = []
325
+ const flags = new Set<string>()
326
+ for (let i = start; i < tokens.length; i += 1) {
327
+ const token = tokens[i]!
328
+ if (token === '--') {
329
+ positionals.push(...tokens.slice(i + 1).filter((t) => !REDIRECTION.test(t)))
330
+ break
331
+ }
332
+ const redirect = REDIRECTION.exec(token)
333
+ if (redirect) {
334
+ // A bare operator takes the next token as its target.
335
+ if (redirect[4] === '' && !redirect[3]) i += 1
336
+ continue
337
+ }
338
+ if (token.startsWith('--')) {
339
+ const eq = token.indexOf('=')
340
+ const name = eq >= 0 ? token.slice(2, eq) : token.slice(2)
341
+ flags.add(name)
342
+ if (eq < 0 && !BOOLEAN_FLAGS.has(name) && i + 1 < tokens.length && !tokens[i + 1]!.startsWith('-')) i += 1
343
+ continue
344
+ }
345
+ if (token.startsWith('-')) continue
346
+ positionals.push(token)
347
+ }
348
+
349
+ const [noun, verb, ...rest] = positionals
350
+ if (!noun || !verb) continue
351
+
352
+ const proxy = AGENT_PROXY_NOUNS[noun]
353
+ if (proxy) {
354
+ if (!proxy.verbs.includes(verb)) continue
355
+ out.push({ kind: 'agent', verb: `${noun}-${verb}`, ref: rest[proxy.agentAt] ?? null, live: false })
356
+ continue
357
+ }
358
+
359
+ const kind = NOUN_ALIASES[noun] ?? (isKind(noun) ? noun : null)
360
+ if (!kind) continue
361
+ if (!TOUCHING_VERBS[kind].includes(verb)) continue
362
+ const ref = (FILE_POSITIONAL_VERBS[kind] ?? []).includes(verb)
363
+ ? null
364
+ : kind === 'blueprint' && BLUEPRINT_KINDED_VERBS.has(verb)
365
+ ? (rest[1] ?? null)
366
+ : (rest[0] ?? null)
367
+ const live =
368
+ (LIVE_VERBS[kind] ?? []).includes(verb)
369
+ // `app deploy` and `function deploy` build and promote in one act
370
+ // unless told not to.
371
+ || ((kind === 'app' || kind === 'automation') && verb === 'deploy' && !flags.has('no-promote'))
372
+ out.push({ kind, verb, ref, live })
373
+ }
374
+ return out
375
+ }
376
+
377
+ /**
378
+ * Feed it the run's chunks; it answers with the touches that just became
379
+ * certain. A `Bash` input holding a `frontera` command is only a CANDIDATE
380
+ * until its result arrives: success records it, an error discards it.
381
+ */
382
+ export class TouchTracker {
383
+ private readonly pending = new Map<string, ParsedFronteraCommand[]>()
384
+
385
+ observe(chunk: UIMessageChunk): TouchInput[] {
386
+ switch (chunk.type) {
387
+ case 'tool-input-available': {
388
+ if (chunk.toolName === 'Bash') {
389
+ const input = chunk.input as { command?: unknown } | undefined
390
+ if (!input || typeof input.command !== 'string') return []
391
+ const parsed = parseFronteraCommands(input.command)
392
+ if (parsed.length > 0) this.pending.set(chunk.toolCallId, parsed)
393
+ return []
394
+ }
395
+ const mcp = parseMcpTool(chunk.toolName, chunk.input)
396
+ if (mcp) this.pending.set(chunk.toolCallId, [mcp])
397
+ return []
398
+ }
399
+ case 'tool-output-available': {
400
+ const parsed = this.pending.get(chunk.toolCallId)
401
+ if (!parsed) return []
402
+ this.pending.delete(chunk.toolCallId)
403
+ return parsed.map((p) => ({ ...p, toolCallId: chunk.toolCallId }))
404
+ }
405
+ case 'tool-output-error':
406
+ this.pending.delete(chunk.toolCallId)
407
+ return []
408
+ default:
409
+ return []
410
+ }
411
+ }
412
+ }
@@ -50,7 +50,7 @@ export function contextPath(root: string): string {
50
50
  return join(root, CONTEXT_DIR, CONTEXT_FILE)
51
51
  }
52
52
 
53
- function readContextAt(root: string, env: Env): FoundContext | null {
53
+ export function readContextAt(root: string, env: Env): FoundContext | null {
54
54
  const path = contextPath(root)
55
55
  if (!existsSync(path)) return null
56
56