@frontera-sdk/cli 1.46.6 → 1.47.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.
@@ -0,0 +1,189 @@
1
+ /**
2
+ * The skills Claude Code would load for a session, discovered the way it
3
+ * discovers them.
4
+ *
5
+ * Ported from T3 Code's `apps/server/src/provider/Drivers/ClaudeSkills.ts`,
6
+ * whose header states the reason plainly: the Agent SDK's init handshake
7
+ * surfaces skills only as slash commands without their filesystem paths, so
8
+ * the only way to show a name AND what it is for is to scan the same
9
+ * directories the CLI scans.
10
+ *
11
+ * Claude Code reads, in order: the config directory's `skills/`, then the
12
+ * workspace's `.agents/skills` and `.claude/skills`, with later roots winning
13
+ * on a name collision. Installed plugins add their own, namespaced
14
+ * `<plugin>:<skill>` — which is how the Frontera kit's eight appear once
15
+ * `frontera setup` has run.
16
+ */
17
+ import { readFileSync, readdirSync, statSync } from 'node:fs'
18
+ import { homedir } from 'node:os'
19
+ import { join } from 'node:path'
20
+
21
+ import type { ForgeSkill } from '@frontera-sdk/forge-contracts'
22
+
23
+ const FRONTMATTER = /^---\r?\n([\s\S]*?)\r?\n---(?:\r?\n|$)/
24
+
25
+ /**
26
+ * Enough YAML for a skill header, and no more.
27
+ *
28
+ * `name` and `description` are single-line scalars in every SKILL.md the
29
+ * hosts ship; pulling in a parser to read two keys would be the larger risk.
30
+ * A description that spans lines keeps its first line, which is what a menu
31
+ * row shows anyway.
32
+ */
33
+ function parseFrontmatter(contents: string): { name?: string; description?: string } | null {
34
+ const match = FRONTMATTER.exec(contents)
35
+ if (!match) return null
36
+ const body = match[1] ?? ''
37
+ const read = (key: string): string | undefined => {
38
+ const line = new RegExp(`^${key}:\\s*(.+)$`, 'm').exec(body)
39
+ if (!line) return undefined
40
+ const raw = (line[1] ?? '').trim()
41
+ const unquoted = /^(['"])([\s\S]*)\1$/.exec(raw)
42
+ const value = (unquoted?.[2] ?? raw).trim()
43
+ return value || undefined
44
+ }
45
+ const name = read('name')
46
+ const description = read('description')
47
+ return {
48
+ ...(name ? { name } : {}),
49
+ ...(description ? { description } : {}),
50
+ }
51
+ }
52
+
53
+ function readSkillDir(dir: string): Array<{ dirName: string; path: string; contents: string }> {
54
+ let entries: string[]
55
+ try {
56
+ entries = readdirSync(dir)
57
+ } catch {
58
+ return []
59
+ }
60
+ const out: Array<{ dirName: string; path: string; contents: string }> = []
61
+ for (const entry of [...entries].sort()) {
62
+ const path = join(dir, entry, 'SKILL.md')
63
+ try {
64
+ out.push({ dirName: entry, path, contents: readFileSync(path, 'utf8') })
65
+ } catch {
66
+ // Not a skill directory.
67
+ }
68
+ }
69
+ return out
70
+ }
71
+
72
+ /** The config directory the spawned CLI would use. */
73
+ export function claudeConfigDir(env: NodeJS.ProcessEnv = process.env): string {
74
+ const configured = env.CLAUDE_CONFIG_DIR?.trim()
75
+ if (configured) return configured
76
+ return join(homedir(), '.claude')
77
+ }
78
+
79
+ /**
80
+ * Installed plugins, as `<marketplace>/<plugin>/<version>/skills`.
81
+ *
82
+ * Their skills are namespaced by plugin — `frontera:using-frontera` — because
83
+ * that is how Claude Code addresses them, and a bare `using-frontera` would
84
+ * collide with a project skill of the same name.
85
+ */
86
+ function pluginSkillRoots(configDir: string): Array<{ plugin: string; dir: string }> {
87
+ const cache = join(configDir, 'plugins', 'cache')
88
+ const roots: Array<{ plugin: string; dir: string }> = []
89
+ let marketplaces: string[]
90
+ try {
91
+ marketplaces = readdirSync(cache)
92
+ } catch {
93
+ return []
94
+ }
95
+ for (const marketplace of marketplaces.sort()) {
96
+ let plugins: string[]
97
+ try {
98
+ plugins = readdirSync(join(cache, marketplace))
99
+ } catch {
100
+ continue
101
+ }
102
+ for (const plugin of plugins.sort()) {
103
+ let versions: string[]
104
+ try {
105
+ versions = readdirSync(join(cache, marketplace, plugin))
106
+ } catch {
107
+ continue
108
+ }
109
+ // Newest version wins when a cache holds more than one.
110
+ for (const version of versions.sort().reverse()) {
111
+ const dir = join(cache, marketplace, plugin, version, 'skills')
112
+ try {
113
+ if (statSync(dir).isDirectory()) {
114
+ roots.push({ plugin, dir })
115
+ break
116
+ }
117
+ } catch {
118
+ // Next version.
119
+ }
120
+ }
121
+ }
122
+ }
123
+ return roots
124
+ }
125
+
126
+ /**
127
+ * Every skill available to a session in `cwd`, in menu order.
128
+ *
129
+ * Keyed by the id the model is addressed with, so a project skill shadowing a
130
+ * user one collapses to a single row — the same resolution Claude Code
131
+ * applies when it loads them.
132
+ */
133
+ export function discoverClaudeSkills(cwd: string, env: NodeJS.ProcessEnv = process.env): ForgeSkill[] {
134
+ const configDir = claudeConfigDir(env)
135
+ const byId = new Map<string, ForgeSkill>()
136
+
137
+ const roots: Array<{ dir: string; source: ForgeSkill['source']; plugin?: string }> = [
138
+ { dir: join(configDir, 'skills'), source: 'user' },
139
+ ...(cwd
140
+ ? [
141
+ { dir: join(cwd, '.agents', 'skills'), source: 'project' as const },
142
+ { dir: join(cwd, '.claude', 'skills'), source: 'project' as const },
143
+ ]
144
+ : []),
145
+ ...pluginSkillRoots(configDir).map((root) => ({
146
+ dir: root.dir,
147
+ source: 'plugin' as const,
148
+ plugin: root.plugin,
149
+ })),
150
+ ]
151
+
152
+ for (const root of roots) {
153
+ for (const found of readSkillDir(root.dir)) {
154
+ const frontmatter = parseFrontmatter(found.contents)
155
+ // No frontmatter means Claude Code will not load it either.
156
+ if (!frontmatter) continue
157
+ const name = frontmatter.name ?? found.dirName.trim()
158
+ if (!name) continue
159
+ const id = root.plugin ? `${root.plugin}:${name}` : name
160
+ byId.set(id, {
161
+ id,
162
+ name,
163
+ source: root.source,
164
+ path: found.path,
165
+ ...(root.plugin ? { plugin: root.plugin } : {}),
166
+ ...(frontmatter.description ? { description: frontmatter.description } : {}),
167
+ })
168
+ }
169
+ }
170
+
171
+ return [...byId.values()].sort((a, b) => a.id.localeCompare(b.id))
172
+ }
173
+
174
+ /** Cheap cache: a menu opens often, and the disk answer rarely changes. */
175
+ const CACHE_MS = 30_000
176
+ const cache = new Map<string, { at: number; skills: ForgeSkill[] }>()
177
+
178
+ export function skillsFor(cwd: string, env: NodeJS.ProcessEnv = process.env): ForgeSkill[] {
179
+ const hit = cache.get(cwd)
180
+ if (hit && Date.now() - hit.at < CACHE_MS) return hit.skills
181
+ const skills = discoverClaudeSkills(cwd, env)
182
+ cache.set(cwd, { at: Date.now(), skills })
183
+ return skills
184
+ }
185
+
186
+ /** Exported for tests: forget what was read. */
187
+ export function clearSkillCache(): void {
188
+ cache.clear()
189
+ }
@@ -0,0 +1,361 @@
1
+ import type { SDKMessage } from '@anthropic-ai/claude-agent-sdk'
2
+ import type { UIMessageChunk } from 'ai'
3
+
4
+ /**
5
+ * Claude Agent SDK messages → AI SDK v6 `UIMessageChunk`s, the vocabulary the
6
+ * platform's chat components already render. Mirrors the shape of the Codex
7
+ * translator in the service (`codex-events.ts`): a stateful object fed one
8
+ * native message at a time, returning the chunks it implies.
9
+ *
10
+ * With `includePartialMessages` the SDK forwards raw Anthropic stream events
11
+ * (`message_start`, `content_block_*`, `message_stop`) alongside the complete
12
+ * `assistant` messages. The stream events carry the deltas; the complete
13
+ * messages are only a safety net for tool inputs when a partial stream was
14
+ * absent. Tool results arrive as `user` messages holding `tool_result`
15
+ * blocks. Sub-agent traffic (`parent_tool_use_id` set) is dropped: the UI
16
+ * shows the parent `Task` tool card, not its inner conversation.
17
+ */
18
+
19
+ type Chunk = UIMessageChunk
20
+
21
+ interface ContentBlock {
22
+ type: string
23
+ id?: string
24
+ name?: string
25
+ text?: string
26
+ thinking?: string
27
+ input?: unknown
28
+ tool_use_id?: string
29
+ content?: unknown
30
+ is_error?: boolean
31
+ }
32
+
33
+ interface StreamEvent {
34
+ type: string
35
+ index?: number
36
+ content_block?: ContentBlock
37
+ delta?: { type?: string; text?: string; thinking?: string; partial_json?: string }
38
+ }
39
+
40
+ /** The fields this translator reads, typed loosely so a fixture is a plain object. */
41
+ interface LooseMessage {
42
+ type: string
43
+ subtype?: string
44
+ session_id?: string
45
+ parent_tool_use_id?: string | null
46
+ isReplay?: boolean
47
+ event?: StreamEvent
48
+ message?: { content?: unknown }
49
+ is_error?: boolean
50
+ result?: string
51
+ errors?: string[]
52
+ usage?: { input_tokens?: number; output_tokens?: number; cache_read_input_tokens?: number }
53
+ }
54
+
55
+ interface OpenBlock {
56
+ kind: 'text' | 'thinking' | 'tool_use' | 'other'
57
+ id: string
58
+ toolName: string
59
+ json: string
60
+ }
61
+
62
+ export interface TranslatorUsage {
63
+ input: number
64
+ output: number
65
+ cachedInput: number
66
+ }
67
+
68
+ export function stringifyToolResult(content: unknown): string {
69
+ if (typeof content === 'string') return content
70
+ if (!Array.isArray(content)) return content == null ? '' : JSON.stringify(content)
71
+ return content
72
+ .map((part: { type?: string; text?: string }) => {
73
+ if (part && part.type === 'text' && typeof part.text === 'string') return part.text
74
+ if (part && part.type === 'image') return '[image]'
75
+ return JSON.stringify(part)
76
+ })
77
+ .join('\n')
78
+ }
79
+
80
+ function parseInput(json: string): Record<string, unknown> {
81
+ if (!json.trim()) return {}
82
+ try {
83
+ const parsed = JSON.parse(json) as unknown
84
+ return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? (parsed as Record<string, unknown>) : {}
85
+ } catch {
86
+ return {}
87
+ }
88
+ }
89
+
90
+ export class ClaudeEventTranslator {
91
+ private started = false
92
+ private stepOpen = false
93
+ private stepHasContent = false
94
+ private finished = false
95
+ private sawStreamEvents = false
96
+ private partCount = 0
97
+ private readonly blocks = new Map<number, OpenBlock>()
98
+ private readonly toolStarts = new Set<string>()
99
+ private readonly toolInputs = new Set<string>()
100
+
101
+ sessionId: string | null = null
102
+ failed = false
103
+ errorText: string | null = null
104
+ usage: TranslatorUsage | null = null
105
+
106
+ constructor(private readonly messageId: string) {}
107
+
108
+ get isFinished(): boolean {
109
+ return this.finished
110
+ }
111
+
112
+ translate(raw: SDKMessage): Chunk[] {
113
+ const msg = raw as unknown as LooseMessage
114
+ const out: Chunk[] = []
115
+ if (this.finished) return out
116
+
117
+ if (!this.sessionId && typeof msg.session_id === 'string' && msg.session_id) {
118
+ this.sessionId = msg.session_id
119
+ }
120
+
121
+ switch (msg.type) {
122
+ case 'stream_event':
123
+ if (msg.parent_tool_use_id) break
124
+ if (msg.event) this.onStreamEvent(msg.event, out)
125
+ break
126
+ case 'assistant':
127
+ if (msg.parent_tool_use_id) break
128
+ this.onAssistant(msg.message?.content, out)
129
+ break
130
+ case 'user':
131
+ if (msg.parent_tool_use_id || msg.isReplay) break
132
+ this.onUser(msg.message?.content, out)
133
+ break
134
+ case 'result':
135
+ this.onResult(msg, out)
136
+ break
137
+ default:
138
+ break
139
+ }
140
+ return out
141
+ }
142
+
143
+ /**
144
+ * A permission prompt can reach the adapter before this translator has
145
+ * consumed the stream events that introduce the tool call — the SDK answers
146
+ * control requests as they arrive while messages queue for the consumer.
147
+ * The approval chunk needs the tool part to exist, so the adapter asks for
148
+ * it here; if the events already came through, nothing is emitted twice.
149
+ */
150
+ ensureToolCall(toolCallId: string, toolName: string, input: unknown): Chunk[] {
151
+ const out: Chunk[] = []
152
+ this.ensureStarted(out)
153
+ this.ensureStep(out)
154
+ out.push(...this.toolStart(toolCallId, toolName))
155
+ out.push(...this.toolInput(toolCallId, toolName, input))
156
+ return out
157
+ }
158
+
159
+ /** The host threw; say so in the stream and close it. */
160
+ fail(errorText: string): Chunk[] {
161
+ const out: Chunk[] = []
162
+ if (this.finished) return out
163
+ this.failed = true
164
+ this.errorText = errorText
165
+ this.ensureStarted(out)
166
+ out.push({ type: 'error', errorText } as Chunk)
167
+ out.push(...this.finish())
168
+ return out
169
+ }
170
+
171
+ /** Close the stream if the host ended without a `result`. */
172
+ finish(): Chunk[] {
173
+ const out: Chunk[] = []
174
+ if (this.finished) return out
175
+ this.ensureStarted(out)
176
+ this.closeStep(out)
177
+ out.push({ type: 'finish' } as Chunk)
178
+ this.finished = true
179
+ return out
180
+ }
181
+
182
+ // -- internals ------------------------------------------------------------
183
+
184
+ private nextId(prefix: string): string {
185
+ return `${this.messageId}_${prefix}${this.partCount++}`
186
+ }
187
+
188
+ private ensureStarted(out: Chunk[]): void {
189
+ if (this.started) return
190
+ this.started = true
191
+ out.push({ type: 'start', messageId: this.messageId } as Chunk)
192
+ }
193
+
194
+ private ensureStep(out: Chunk[]): void {
195
+ if (this.stepOpen) return
196
+ this.stepOpen = true
197
+ this.stepHasContent = false
198
+ out.push({ type: 'start-step' } as Chunk)
199
+ }
200
+
201
+ private closeStep(out: Chunk[]): void {
202
+ if (!this.stepOpen) return
203
+ this.stepOpen = false
204
+ out.push({ type: 'finish-step' } as Chunk)
205
+ }
206
+
207
+ /** A new API message is a new step, unless the open one is still empty. */
208
+ private newStep(out: Chunk[]): void {
209
+ if (this.stepOpen && this.stepHasContent) this.closeStep(out)
210
+ this.ensureStep(out)
211
+ }
212
+
213
+ private toolStart(toolCallId: string, toolName: string): Chunk[] {
214
+ if (this.toolStarts.has(toolCallId)) return []
215
+ this.toolStarts.add(toolCallId)
216
+ this.stepHasContent = true
217
+ return [{ type: 'tool-input-start', toolCallId, toolName } as Chunk]
218
+ }
219
+
220
+ private toolInput(toolCallId: string, toolName: string, input: unknown): Chunk[] {
221
+ if (this.toolInputs.has(toolCallId)) return []
222
+ this.toolInputs.add(toolCallId)
223
+ return [{ type: 'tool-input-available', toolCallId, toolName, input: input ?? {} } as Chunk]
224
+ }
225
+
226
+ private onStreamEvent(ev: StreamEvent, out: Chunk[]): void {
227
+ this.sawStreamEvents = true
228
+ switch (ev.type) {
229
+ case 'message_start': {
230
+ this.ensureStarted(out)
231
+ this.newStep(out)
232
+ this.blocks.clear()
233
+ break
234
+ }
235
+ case 'content_block_start': {
236
+ const block = ev.content_block
237
+ if (!block || ev.index === undefined) break
238
+ this.ensureStarted(out)
239
+ this.ensureStep(out)
240
+ if (block.type === 'text') {
241
+ const id = this.nextId('t')
242
+ this.blocks.set(ev.index, { kind: 'text', id, toolName: '', json: '' })
243
+ this.stepHasContent = true
244
+ out.push({ type: 'text-start', id } as Chunk)
245
+ } else if (block.type === 'thinking' || block.type === 'redacted_thinking') {
246
+ const id = this.nextId('r')
247
+ this.blocks.set(ev.index, { kind: 'thinking', id, toolName: '', json: '' })
248
+ this.stepHasContent = true
249
+ out.push({ type: 'reasoning-start', id } as Chunk)
250
+ } else if (block.type === 'tool_use' || block.type === 'server_tool_use' || block.type === 'mcp_tool_use') {
251
+ const id = block.id ?? this.nextId('call')
252
+ const toolName = block.name ?? 'tool'
253
+ this.blocks.set(ev.index, { kind: 'tool_use', id, toolName, json: '' })
254
+ out.push(...this.toolStart(id, toolName))
255
+ } else {
256
+ this.blocks.set(ev.index, { kind: 'other', id: '', toolName: '', json: '' })
257
+ }
258
+ break
259
+ }
260
+ case 'content_block_delta': {
261
+ if (ev.index === undefined) break
262
+ const block = this.blocks.get(ev.index)
263
+ const delta = ev.delta
264
+ if (!block || !delta) break
265
+ if (block.kind === 'text' && delta.type === 'text_delta' && typeof delta.text === 'string') {
266
+ out.push({ type: 'text-delta', id: block.id, delta: delta.text } as Chunk)
267
+ } else if (block.kind === 'thinking' && delta.type === 'thinking_delta' && typeof delta.thinking === 'string') {
268
+ out.push({ type: 'reasoning-delta', id: block.id, delta: delta.thinking } as Chunk)
269
+ } else if (block.kind === 'tool_use' && delta.type === 'input_json_delta' && typeof delta.partial_json === 'string') {
270
+ block.json += delta.partial_json
271
+ if (!this.toolInputs.has(block.id)) {
272
+ out.push({ type: 'tool-input-delta', toolCallId: block.id, inputTextDelta: delta.partial_json } as Chunk)
273
+ }
274
+ }
275
+ break
276
+ }
277
+ case 'content_block_stop': {
278
+ if (ev.index === undefined) break
279
+ const block = this.blocks.get(ev.index)
280
+ if (!block) break
281
+ this.blocks.delete(ev.index)
282
+ if (block.kind === 'text') out.push({ type: 'text-end', id: block.id } as Chunk)
283
+ else if (block.kind === 'thinking') out.push({ type: 'reasoning-end', id: block.id } as Chunk)
284
+ else if (block.kind === 'tool_use') out.push(...this.toolInput(block.id, block.toolName, parseInput(block.json)))
285
+ break
286
+ }
287
+ default:
288
+ // message_delta / message_stop / ping carry nothing the UI shows.
289
+ break
290
+ }
291
+ }
292
+
293
+ private onAssistant(content: unknown, out: Chunk[]): void {
294
+ if (!Array.isArray(content)) return
295
+ this.ensureStarted(out)
296
+ if (!this.sawStreamEvents) this.newStep(out)
297
+ for (const block of content as ContentBlock[]) {
298
+ if (!block || typeof block !== 'object') continue
299
+ if (block.type === 'tool_use' || block.type === 'server_tool_use' || block.type === 'mcp_tool_use') {
300
+ const id = block.id ?? this.nextId('call')
301
+ const toolName = block.name ?? 'tool'
302
+ this.ensureStep(out)
303
+ out.push(...this.toolStart(id, toolName))
304
+ out.push(...this.toolInput(id, toolName, block.input ?? {}))
305
+ continue
306
+ }
307
+ // Text and thinking already streamed as deltas when partials are on;
308
+ // repeating them here would double every paragraph.
309
+ if (this.sawStreamEvents) continue
310
+ if (block.type === 'text' && typeof block.text === 'string') {
311
+ const id = this.nextId('t')
312
+ this.stepHasContent = true
313
+ out.push({ type: 'text-start', id } as Chunk)
314
+ out.push({ type: 'text-delta', id, delta: block.text } as Chunk)
315
+ out.push({ type: 'text-end', id } as Chunk)
316
+ } else if (block.type === 'thinking' && typeof block.thinking === 'string') {
317
+ const id = this.nextId('r')
318
+ this.stepHasContent = true
319
+ out.push({ type: 'reasoning-start', id } as Chunk)
320
+ out.push({ type: 'reasoning-delta', id, delta: block.thinking } as Chunk)
321
+ out.push({ type: 'reasoning-end', id } as Chunk)
322
+ }
323
+ }
324
+ }
325
+
326
+ private onUser(content: unknown, out: Chunk[]): void {
327
+ if (!Array.isArray(content)) return
328
+ for (const block of content as ContentBlock[]) {
329
+ if (!block || block.type !== 'tool_result' || typeof block.tool_use_id !== 'string') continue
330
+ // A result for a call the stream never introduced has no part to land
331
+ // on; the AI SDK reducer throws on it, so it is dropped rather than sent.
332
+ if (!this.toolStarts.has(block.tool_use_id)) continue
333
+ const text = stringifyToolResult(block.content)
334
+ if (block.is_error) {
335
+ out.push({ type: 'tool-output-error', toolCallId: block.tool_use_id, errorText: text || 'tool failed' } as Chunk)
336
+ } else {
337
+ out.push({ type: 'tool-output-available', toolCallId: block.tool_use_id, output: text } as Chunk)
338
+ }
339
+ }
340
+ }
341
+
342
+ private onResult(msg: LooseMessage, out: Chunk[]): void {
343
+ this.ensureStarted(out)
344
+ if (msg.usage) {
345
+ this.usage = {
346
+ input: msg.usage.input_tokens ?? 0,
347
+ output: msg.usage.output_tokens ?? 0,
348
+ cachedInput: msg.usage.cache_read_input_tokens ?? 0,
349
+ }
350
+ }
351
+ if (msg.is_error || (msg.subtype && msg.subtype !== 'success')) {
352
+ this.failed = true
353
+ this.errorText =
354
+ (msg.errors && msg.errors.length > 0 ? msg.errors.join('\n') : null)
355
+ ?? (typeof msg.result === 'string' && msg.result ? msg.result : null)
356
+ ?? `Claude Code ended the turn with ${msg.subtype ?? 'an error'}`
357
+ out.push({ type: 'error', errorText: this.errorText } as Chunk)
358
+ }
359
+ out.push(...this.finish())
360
+ }
361
+ }
@@ -0,0 +1,28 @@
1
+ import type { UIMessageChunk } from 'ai'
2
+
3
+ import type { ApprovalDecision, Emit, HostAdapter, TurnContext, TurnOutcome } from './adapter'
4
+
5
+ const NOT_WIRED = 'Codex is not wired in this build yet — use Claude Code'
6
+
7
+ /**
8
+ * Placeholder until the Codex app-server adapter lands. It speaks the same
9
+ * `HostAdapter` contract so the run manager, the store and the surface need
10
+ * no change when it does; the only thing it says today is why it cannot run.
11
+ */
12
+ export class CodexAdapter implements HostAdapter {
13
+ async startTurn(ctx: TurnContext, emit: Emit): Promise<TurnOutcome> {
14
+ const chunks: UIMessageChunk[] = [
15
+ { type: 'start', messageId: ctx.messageId } as UIMessageChunk,
16
+ { type: 'error', errorText: NOT_WIRED } as UIMessageChunk,
17
+ { type: 'finish' } as UIMessageChunk,
18
+ ]
19
+ for (const chunk of chunks) emit(chunk)
20
+ return { error: NOT_WIRED }
21
+ }
22
+
23
+ async interrupt(): Promise<void> {}
24
+
25
+ respondApproval(_approvalId: string, _decision: ApprovalDecision): boolean {
26
+ return false
27
+ }
28
+ }
@@ -0,0 +1,42 @@
1
+ import { homedir } from 'node:os'
2
+ import { delimiter, dirname, join } from 'node:path'
3
+
4
+ import { dataDir } from '../paths'
5
+
6
+ /**
7
+ * The PATH every process Forge spawns sees: hosts, terminals, and the hosts'
8
+ * own subprocesses.
9
+ *
10
+ * `frontera` goes first so the agent and the person type the same command and
11
+ * reach the same binary — the CLI that owns this daemon, not whichever older
12
+ * copy a package manager left on the machine. The well-known tool directories
13
+ * follow because a daemon launched by a GUI app inherits the login PATH, not
14
+ * the shell's, and that is where `claude` and `codex` usually live.
15
+ */
16
+ export function forgePath(base: string | undefined = process.env.PATH): string {
17
+ const home = homedir()
18
+ const first = [join(dataDir(), 'bin'), dirname(process.execPath)]
19
+ const wellKnown = [
20
+ join(home, '.local', 'bin'),
21
+ join(home, '.bun', 'bin'),
22
+ join(home, '.claude', 'local'),
23
+ '/opt/homebrew/bin',
24
+ '/usr/local/bin',
25
+ ]
26
+ const seen = new Set<string>()
27
+ const out: string[] = []
28
+ for (const dir of [...first, ...(base ? base.split(delimiter) : []), ...wellKnown]) {
29
+ if (!dir || seen.has(dir)) continue
30
+ seen.add(dir)
31
+ out.push(dir)
32
+ }
33
+ return out.join(delimiter)
34
+ }
35
+
36
+ export function forgeEnv(extra: Record<string, string | undefined> = {}): Record<string, string | undefined> {
37
+ const env: Record<string, string | undefined> = { ...process.env, PATH: forgePath(), ...extra }
38
+ // Set by an Electron parent to run its own binary as Node; inherited by a
39
+ // child it turns every `electron`-based tool on PATH into a bare Node.
40
+ delete env.ELECTRON_RUN_AS_NODE
41
+ return env
42
+ }