@frontera-sdk/cli 1.46.6 → 1.46.7

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,323 @@
1
+ import { spawnSync } from 'node:child_process'
2
+
3
+ import type {
4
+ ForgeEffort,
5
+ ForgeHostId,
6
+ ForgeHostInfo,
7
+ ForgeHostMode,
8
+ ForgeHostModel,
9
+ ForgeSessionView,
10
+ } from '@frontera-sdk/forge-contracts'
11
+
12
+ import { forgeEnv } from './env'
13
+
14
+ /**
15
+ * What the daemon knows about each coding host without asking it much.
16
+ *
17
+ * Modes are the host's own: Claude Code's `permissionMode` values and Codex's
18
+ * sandbox/approval vocabulary, verbatim. Forge does not define a fourth set
19
+ * and map into it — the developer already knows their harness's modes, and a
20
+ * translation layer is only a place for the two to disagree.
21
+ *
22
+ * Model catalogs are a v1 stub: neither host exposes a cheap "list models"
23
+ * call, so these are the ids the hosts document today. Any id the client
24
+ * passes is accepted as-is (see `modelsFor`), which is how a newer model
25
+ * reaches the picker before this list learns about it.
26
+ */
27
+ export interface HostDefinition {
28
+ id: ForgeHostId
29
+ label: string
30
+ bin: string
31
+ loginCommand: string
32
+ models: ForgeHostModel[]
33
+ defaultModel: string
34
+ modes: ForgeHostMode[]
35
+ defaultMode: string
36
+ }
37
+
38
+ /** Claude Code's effort levels, in the ids the platform's picker renders. */
39
+ const CLAUDE_EFFORTS: ForgeEffort[] = ['low', 'medium', 'high', 'xhigh']
40
+ const CLAUDE_DEFAULT_EFFORT: ForgeEffort = 'high'
41
+
42
+ /**
43
+ * Claude Code's own mode picker, verbatim.
44
+ *
45
+ * Ids are the Agent SDK's `PermissionMode`; labels, descriptions and order are
46
+ * the ones Claude Code shows in its own menu. Forge writes none of this — a
47
+ * developer who switches between the CLI and this surface should not have to
48
+ * learn a second name for the same setting, and a third vocabulary is how one
49
+ * feature ends up explaining itself differently in every place it appears.
50
+ *
51
+ * `dontAsk` is the one SDK value left out: it denies anything not pre-approved,
52
+ * which needs an allow-list Forge has no UI for yet.
53
+ */
54
+ const CLAUDE_MODES: ForgeHostMode[] = [
55
+ {
56
+ id: 'auto',
57
+ label: 'Auto',
58
+ description: 'Claude handles permission decisions',
59
+ },
60
+ {
61
+ id: 'default',
62
+ label: 'Manual',
63
+ description: 'Always ask before making changes',
64
+ },
65
+ {
66
+ id: 'acceptEdits',
67
+ label: 'Accept edits',
68
+ description: 'Automatically accept all file edits',
69
+ },
70
+ {
71
+ id: 'plan',
72
+ label: 'Plan',
73
+ description: 'Create a plan before making changes',
74
+ },
75
+ // Claude Code offers this as a separate "Enable" rather than a listed mode,
76
+ // and gives it no subtitle. Neither do we.
77
+ {
78
+ id: 'bypassPermissions',
79
+ label: 'Bypass permissions',
80
+ },
81
+ ]
82
+
83
+ /**
84
+ * Codex's own sandbox modes, verbatim.
85
+ *
86
+ * Ids are its `--sandbox` values; labels and descriptions are the strings the
87
+ * Codex binary itself renders. Declarative until its adapter lands.
88
+ */
89
+ const CODEX_MODES: ForgeHostMode[] = [
90
+ {
91
+ id: 'read-only',
92
+ label: 'Read Only',
93
+ description:
94
+ 'Codex can read files in the current workspace. Approval is required to edit files or access the internet.',
95
+ },
96
+ {
97
+ id: 'workspace-write',
98
+ label: 'Default',
99
+ description:
100
+ 'Codex can read and edit files in the current workspace, and run commands. Approval is required to access the internet or edit other files.',
101
+ },
102
+ {
103
+ id: 'danger-full-access',
104
+ label: 'Full Access',
105
+ description:
106
+ 'Codex can edit files outside this workspace and access the internet without asking for approval. Exercise caution when using.',
107
+ },
108
+ ]
109
+
110
+ export const HOSTS: Record<ForgeHostId, HostDefinition> = {
111
+ claude: {
112
+ id: 'claude',
113
+ label: 'Claude Code',
114
+ bin: 'claude',
115
+ loginCommand: 'claude auth login',
116
+ models: [
117
+ { id: 'claude-opus-5', label: 'Claude Opus 5', efforts: CLAUDE_EFFORTS, defaultEffort: CLAUDE_DEFAULT_EFFORT },
118
+ { id: 'claude-fable-5', label: 'Claude Fable 5', efforts: CLAUDE_EFFORTS, defaultEffort: CLAUDE_DEFAULT_EFFORT },
119
+ { id: 'claude-sonnet-5', label: 'Claude Sonnet 5', efforts: CLAUDE_EFFORTS, defaultEffort: CLAUDE_DEFAULT_EFFORT },
120
+ {
121
+ id: 'claude-haiku-4-5-20251001',
122
+ label: 'Claude Haiku 4.5',
123
+ efforts: CLAUDE_EFFORTS,
124
+ defaultEffort: CLAUDE_DEFAULT_EFFORT,
125
+ },
126
+ ],
127
+ defaultModel: 'claude-sonnet-5',
128
+ modes: CLAUDE_MODES,
129
+ defaultMode: 'auto',
130
+ },
131
+ codex: {
132
+ id: 'codex',
133
+ label: 'Codex',
134
+ bin: 'codex',
135
+ loginCommand: 'codex login',
136
+ models: [{ id: 'gpt-5-codex', label: 'GPT-5 Codex', efforts: ['low', 'medium', 'high'], defaultEffort: 'medium' }],
137
+ defaultModel: 'gpt-5-codex',
138
+ modes: CODEX_MODES,
139
+ defaultMode: 'workspace-write',
140
+ },
141
+ }
142
+
143
+ export const HOST_IDS = Object.keys(HOSTS) as ForgeHostId[]
144
+
145
+ export function isHostId(value: unknown): value is ForgeHostId {
146
+ return typeof value === 'string' && value in HOSTS
147
+ }
148
+
149
+ /** Mode ids this host declares, for validation and for the 400 that names them. */
150
+ export function modeIdsFor(id: ForgeHostId): string[] {
151
+ return HOSTS[id].modes.map((m) => m.id)
152
+ }
153
+
154
+ export function isHostMode(id: ForgeHostId, value: unknown): value is string {
155
+ return typeof value === 'string' && modeIdsFor(id).includes(value)
156
+ }
157
+
158
+ /** The catalog entry for a model id, when the host documents it. */
159
+ export function hostModel(id: ForgeHostId, model: string | null): ForgeHostModel | null {
160
+ if (!model) return null
161
+ return HOSTS[id].models.find((m) => m.id === model) ?? null
162
+ }
163
+
164
+ /** The effort a freshly chosen model starts on; null when it takes none. */
165
+ export function defaultEffortFor(id: ForgeHostId, model: string | null): string | null {
166
+ return hostModel(id, model)?.defaultEffort ?? null
167
+ }
168
+
169
+ export interface HostProbe {
170
+ installed: boolean
171
+ version?: string
172
+ authenticated?: boolean
173
+ }
174
+
175
+ /** A host CLI that hangs must not hang the readiness probe with it. */
176
+ const PROBE_TIMEOUT_MS = 4_000
177
+ const CACHE_TTL_MS = 60_000
178
+
179
+ /**
180
+ * How a host binary is probed. Exported so a caller that must not touch the
181
+ * machine — every test of the daemon — can supply its own.
182
+ */
183
+ export interface Exec {
184
+ (bin: string, args: string[]): { missing: boolean; ok: boolean; out: string }
185
+ }
186
+
187
+ const defaultExec: Exec = (bin, args) => {
188
+ const res = spawnSync(bin, args, {
189
+ encoding: 'utf8',
190
+ timeout: PROBE_TIMEOUT_MS,
191
+ env: forgeEnv() as NodeJS.ProcessEnv,
192
+ })
193
+ if (res.error && (res.error as NodeJS.ErrnoException).code === 'ENOENT') {
194
+ return { missing: true, ok: false, out: '' }
195
+ }
196
+ return { missing: false, ok: res.status === 0, out: `${res.stdout ?? ''}${res.stderr ?? ''}`.trim() }
197
+ }
198
+
199
+ function parseVersion(out: string): string | undefined {
200
+ return out.match(/\b(\d+\.\d+\.\d+)\b/)?.[1]
201
+ }
202
+
203
+ /**
204
+ * `claude auth status` prints JSON in recent builds and prose in older ones;
205
+ * either way exit 0 means a session exists. A non-zero exit is "unknown":
206
+ * the subcommand may simply not exist in this version, and reporting a
207
+ * signed-in developer as signed out would send them to a login they already
208
+ * did.
209
+ */
210
+ function claudeAuth(exec: Exec): boolean | undefined {
211
+ const res = exec('claude', ['auth', 'status'])
212
+ if (res.missing) return undefined
213
+ const fromJson = authFromJson(res.out)
214
+ if (fromJson !== undefined) return fromJson
215
+ return res.ok ? true : undefined
216
+ }
217
+
218
+ function authFromJson(out: string): boolean | undefined {
219
+ const start = out.indexOf('{')
220
+ if (start === -1) return undefined
221
+ try {
222
+ const parsed = JSON.parse(out.slice(start)) as Record<string, unknown>
223
+ for (const key of ['loggedIn', 'isLoggedIn', 'authenticated', 'isAuthenticated']) {
224
+ if (typeof parsed[key] === 'boolean') return parsed[key] as boolean
225
+ }
226
+ } catch {
227
+ // Prose output; fall through to the exit code.
228
+ }
229
+ return undefined
230
+ }
231
+
232
+ /** `codex login status` exits non-zero when nobody is signed in. */
233
+ function codexAuth(exec: Exec): boolean | undefined {
234
+ const res = exec('codex', ['login', 'status'])
235
+ if (res.missing) return undefined
236
+ return res.ok
237
+ }
238
+
239
+ export function probeHost(id: ForgeHostId, exec: Exec = defaultExec): HostProbe {
240
+ const host = HOSTS[id]
241
+ const version = exec(host.bin, ['--version'])
242
+ if (version.missing) return { installed: false }
243
+ const probe: HostProbe = { installed: true }
244
+ const v = parseVersion(version.out)
245
+ if (v) probe.version = v
246
+ const auth = id === 'claude' ? claudeAuth(exec) : codexAuth(exec)
247
+ if (auth !== undefined) probe.authenticated = auth
248
+ return probe
249
+ }
250
+
251
+ const cache = new Map<ForgeHostId, { at: number; probe: HostProbe }>()
252
+
253
+ export function probeHostCached(id: ForgeHostId, exec?: Exec, now = Date.now()): HostProbe {
254
+ // The cache is keyed by host id alone, so it can only ever hold answers from
255
+ // the real machine. An injected `exec` asks a different question and neither
256
+ // reads that cache nor writes to it.
257
+ if (exec) return probeHost(id, exec)
258
+ const hit = cache.get(id)
259
+ if (hit && now - hit.at < CACHE_TTL_MS) return hit.probe
260
+ const probe = probeHost(id, exec)
261
+ cache.set(id, { at: now, probe })
262
+ return probe
263
+ }
264
+
265
+ export function hostInfo(id: ForgeHostId, exec?: Exec): ForgeHostInfo {
266
+ const host = HOSTS[id]
267
+ const probe = probeHostCached(id, exec)
268
+ return {
269
+ id,
270
+ label: host.label,
271
+ installed: probe.installed,
272
+ ...(probe.version ? { version: probe.version } : {}),
273
+ ...(probe.authenticated !== undefined ? { authenticated: probe.authenticated } : {}),
274
+ models: host.models,
275
+ defaultModel: host.defaultModel,
276
+ modes: host.modes,
277
+ defaultMode: host.defaultMode,
278
+ loginCommand: host.loginCommand,
279
+ }
280
+ }
281
+
282
+ /**
283
+ * Which harness a session opens with when nobody chose one.
284
+ *
285
+ * Claude Code first because its adapter is the one that works; a machine with
286
+ * only Codex still gets a session rather than an error it cannot act on.
287
+ */
288
+ export function firstInstalledHostId(exec?: Exec): ForgeHostId | null {
289
+ const installed = listHosts(exec).filter((host) => host.installed)
290
+ return installed.find((host) => host.id === 'claude')?.id ?? installed[0]?.id ?? null
291
+ }
292
+
293
+ export function listHosts(exec?: Exec): ForgeHostInfo[] {
294
+ return HOST_IDS.map((id) => hostInfo(id, exec))
295
+ }
296
+
297
+ /**
298
+ * The host's catalog keyed by id, in the shape the platform's session payload
299
+ * uses — including the effort block the model picker reads — plus whatever the
300
+ * thread is actually set to, so a model chosen outside the catalog still
301
+ * renders with a name.
302
+ */
303
+ export function modelsFor(id: ForgeHostId, current: string | null): ForgeSessionView['models'] {
304
+ const out: ForgeSessionView['models'] = {}
305
+ for (const m of HOSTS[id].models) {
306
+ out[m.id] = {
307
+ provider: id,
308
+ model: m.id,
309
+ name: m.label,
310
+ ...(m.efforts && m.efforts.length > 0
311
+ ? {
312
+ reasoning: {
313
+ style: 'effort' as const,
314
+ supportedEfforts: m.efforts,
315
+ ...(m.defaultEffort ? { defaultEffort: m.defaultEffort } : {}),
316
+ },
317
+ }
318
+ : {}),
319
+ }
320
+ }
321
+ if (current && !out[current]) out[current] = { provider: id, model: current, name: current }
322
+ return out
323
+ }
@@ -0,0 +1,74 @@
1
+ import type { ForgeEnvelope, ForgeErrorEnvelope } from '@frontera-sdk/forge-contracts'
2
+
3
+ /**
4
+ * A request the daemon refuses, with the status it answers. Thrown from route
5
+ * handlers so the one place that renders errors is the dispatcher, the same
6
+ * way the CLI's own shell owns exit codes.
7
+ */
8
+ export class ForgeHttpError extends Error {
9
+ readonly status: number
10
+ readonly code: string
11
+
12
+ constructor(status: number, message: string, code?: string) {
13
+ super(message)
14
+ this.name = 'ForgeHttpError'
15
+ this.status = status
16
+ this.code = code ?? defaultCode(status)
17
+ }
18
+ }
19
+
20
+ function defaultCode(status: number): string {
21
+ switch (status) {
22
+ case 400:
23
+ return 'BAD_REQUEST'
24
+ case 401:
25
+ return 'UNAUTHORIZED'
26
+ case 403:
27
+ return 'FORBIDDEN'
28
+ case 404:
29
+ return 'NOT_FOUND'
30
+ case 409:
31
+ return 'CONFLICT'
32
+ case 501:
33
+ return 'NOT_IMPLEMENTED'
34
+ default:
35
+ return 'INTERNAL_ERROR'
36
+ }
37
+ }
38
+
39
+ export function okBody<T>(data: T): ForgeEnvelope<T> {
40
+ return { data, message: 'Success', error: false }
41
+ }
42
+
43
+ export function errorBody(message: string, code: string): ForgeErrorEnvelope {
44
+ return { error: true, message, code }
45
+ }
46
+
47
+ export function json(body: unknown, init: ResponseInit & { headers?: Record<string, string> } = {}): Response {
48
+ return new Response(JSON.stringify(body), {
49
+ ...init,
50
+ headers: { 'content-type': 'application/json', ...(init.headers ?? {}) },
51
+ })
52
+ }
53
+
54
+ export function badRequest(message: string): ForgeHttpError {
55
+ return new ForgeHttpError(400, message)
56
+ }
57
+
58
+ export function notFound(what: string): ForgeHttpError {
59
+ return new ForgeHttpError(404, `${what} not found`)
60
+ }
61
+
62
+ /** Read a JSON body, refusing anything that is not an object. */
63
+ export async function readJson<T extends object>(req: Request): Promise<T> {
64
+ let parsed: unknown
65
+ try {
66
+ parsed = await req.json()
67
+ } catch {
68
+ throw badRequest('body must be JSON')
69
+ }
70
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
71
+ throw badRequest('body must be a JSON object')
72
+ }
73
+ return parsed as T
74
+ }
@@ -0,0 +1,53 @@
1
+ import { appendFileSync, existsSync, mkdirSync, statSync, writeFileSync } from 'node:fs'
2
+ import { dirname } from 'node:path'
3
+
4
+ export interface Logger {
5
+ info(message: string): void
6
+ warn(message: string): void
7
+ error(message: string): void
8
+ }
9
+
10
+ /**
11
+ * Truncate rather than rotate. The file exists so a "Forge stopped working"
12
+ * report can attach something; nobody needs last month's daemon chatter, and
13
+ * a rotation scheme is more code than the value it protects.
14
+ */
15
+ const MAX_BYTES = 5 * 1024 * 1024
16
+
17
+ /** How often the size check runs — a stat per line would dominate the write. */
18
+ const CHECK_EVERY = 50
19
+
20
+ export function createLogger(
21
+ file: string | null,
22
+ streams: { err(chunk: string): void } = { err: (chunk) => process.stderr.write(chunk) },
23
+ ): Logger {
24
+ let writes = 0
25
+ if (file) {
26
+ try {
27
+ mkdirSync(dirname(file), { recursive: true })
28
+ } catch {
29
+ file = null
30
+ }
31
+ }
32
+
33
+ const write = (level: string, message: string): void => {
34
+ streams.err(`[forge] ${message}\n`)
35
+ if (!file) return
36
+ try {
37
+ if (writes++ % CHECK_EVERY === 0 && existsSync(file) && statSync(file).size > MAX_BYTES) {
38
+ writeFileSync(file, '')
39
+ }
40
+ appendFileSync(file, `${new Date().toISOString()} ${level} ${message}\n`)
41
+ } catch {
42
+ // Logging must never take the daemon down.
43
+ }
44
+ }
45
+
46
+ return {
47
+ info: (m) => write('info', m),
48
+ warn: (m) => write('warn', m),
49
+ error: (m) => write('error', m),
50
+ }
51
+ }
52
+
53
+ export const silentLogger: Logger = { info() {}, warn() {}, error() {} }