@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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@frontera-sdk/cli",
3
- "version": "1.46.6",
3
+ "version": "1.47.0",
4
4
  "description": "The frontera CLI — scaffold, pull, save and deploy Frontera apps and automations.",
5
5
  "keywords": [
6
6
  "frontera",
@@ -38,12 +38,15 @@
38
38
  "build:release": "bun run scripts/build-release.ts"
39
39
  },
40
40
  "dependencies": {
41
- "@frontera-sdk/automation": "1.46.6",
42
- "@frontera-sdk/core": "1.46.6",
41
+ "@anthropic-ai/claude-agent-sdk": "^0.3.251",
42
+ "@frontera-sdk/automation": "1.47.0",
43
+ "@frontera-sdk/core": "1.47.0",
44
+ "ai": "^6.0.116",
43
45
  "gray-matter": "^4.0.3",
44
46
  "yaml": "^2.9.0"
45
47
  },
46
48
  "devDependencies": {
49
+ "@frontera-sdk/forge-contracts": "1.47.0",
47
50
  "@types/bun": "^1.3.14",
48
51
  "typescript": "^5.9.3"
49
52
  }
@@ -0,0 +1,4 @@
1
+ import { forgeServe } from './serve'
2
+ import type { Command } from '../types'
3
+
4
+ export const forgeCommands: readonly Command[] = [forgeServe]
@@ -0,0 +1,144 @@
1
+ import { randomBytes } from 'node:crypto'
2
+ import { readFileSync } from 'node:fs'
3
+ import { join } from 'node:path'
4
+
5
+ import { UsageError } from '../../errors'
6
+ import { createLogger } from '../../forge/log'
7
+ import { startForgeServer } from '../../forge/server'
8
+ import { dataDir } from '../../paths'
9
+ import { flagString, type Command, type CommandResult } from '../types'
10
+
11
+ /**
12
+ * The Forge daemon. Started by the desktop shell with the platform origin and
13
+ * a token on a file descriptor; usable by hand for development with a printed
14
+ * token instead.
15
+ *
16
+ * Everything the shell needs is one JSON line on stdout — the port, the pid,
17
+ * the version — and nothing else ever goes there. Logs go to stderr and to
18
+ * `~/.local/share/frontera/forge/daemon.log`, so a "Forge stopped working"
19
+ * report can attach the file.
20
+ */
21
+
22
+ const PARENT_CHECK_MS = 5_000
23
+
24
+ async function cliVersion(): Promise<string> {
25
+ const stamped = process.env.FRONTERA_CLI_VERSION
26
+ if (stamped) return stamped
27
+ const pkg = await import('../../../package.json', { with: { type: 'json' } })
28
+ const version = (pkg.default as { version: string }).version
29
+ return import.meta.dir.includes('/node_modules/') ? version : `${version}-dev`
30
+ }
31
+
32
+ function readTokenFromFd(raw: string): string {
33
+ const fd = Number(raw)
34
+ if (!Number.isInteger(fd) || fd < 0) {
35
+ throw new UsageError(
36
+ '--token-fd must be a file descriptor number',
37
+ 'the desktop shell passes `--token-fd 3`; omit the flag to have a token generated',
38
+ )
39
+ }
40
+ let token: string
41
+ try {
42
+ token = readFileSync(fd, 'utf8').trim()
43
+ } catch (err) {
44
+ throw new UsageError(
45
+ `could not read the token from fd ${fd}: ${err instanceof Error ? err.message : String(err)}`,
46
+ 'the parent must open that descriptor, write the token and close it',
47
+ )
48
+ }
49
+ if (!token) throw new UsageError(`fd ${fd} carried no token`, 'the parent must write the token and close the descriptor')
50
+ return token
51
+ }
52
+
53
+ export const forgeServe: Command = {
54
+ meta: {
55
+ noun: 'forge',
56
+ verb: 'serve',
57
+ args: [],
58
+ flags: { port: 'string', origin: 'string', 'token-fd': 'string' },
59
+ summary: 'Run the Forge daemon the desktop app drives Claude Code and Codex through',
60
+ examples: [
61
+ 'frontera forge serve',
62
+ 'frontera forge serve --port 4820 --origin https://acme.frontera.example',
63
+ ],
64
+ // It is the thing other commands' credentials are resolved INSIDE of, per
65
+ // project directory, at turn time. Requiring one here would tie the
66
+ // daemon to whichever directory it happened to start in.
67
+ offline: true,
68
+ },
69
+
70
+ async run(ctx) {
71
+ const portRaw = flagString(ctx, 'port') ?? '0'
72
+ const port = Number(portRaw)
73
+ if (!Number.isInteger(port) || port < 0 || port > 65535) {
74
+ throw new UsageError(
75
+ `--port must be a number between 0 and 65535, got ${portRaw}`,
76
+ 'pass `--port 0` to let the OS choose one',
77
+ )
78
+ }
79
+
80
+ const originRaw = flagString(ctx, 'origin') ?? process.env.FRONTERA_FORGE_ORIGIN ?? null
81
+ const origin = originRaw ? originRaw.replace(/\/+$/, '') : null
82
+
83
+ const tokenFd = flagString(ctx, 'token-fd')
84
+ const token = tokenFd !== undefined ? readTokenFromFd(tokenFd) : randomBytes(32).toString('base64url')
85
+
86
+ const forgeDir = join(dataDir(), 'forge')
87
+ const log = createLogger(join(forgeDir, 'daemon.log'))
88
+ const version = await cliVersion()
89
+
90
+ const server = startForgeServer({
91
+ port,
92
+ token,
93
+ origin,
94
+ // Offline command: the dispatcher resolves no credential, so the origin
95
+ // the shell passed arrives as a flag, not on ctx.
96
+ apiUrl: flagString(ctx, 'api-url') ?? process.env.FRONTERA_API_URL ?? ctx.apiUrl,
97
+ version,
98
+ storePath: join(forgeDir, 'state.sqlite'),
99
+ log,
100
+ })
101
+
102
+ if (tokenFd === undefined) {
103
+ // A generated token has to be told to someone; stderr keeps stdout to
104
+ // its one line and keeps the secret out of the log file.
105
+ process.stderr.write(`[forge] token ${token}\n`)
106
+ }
107
+ if (!origin) log.warn('no --origin given: browsers cannot call this daemon (CORS); curl and the shell can')
108
+
109
+ process.stdout.write(`${JSON.stringify({ forge: { port: server.port, pid: process.pid, version } })}\n`)
110
+
111
+ let stopping = false
112
+ const shutdown = async (reason: string) => {
113
+ if (stopping) return
114
+ stopping = true
115
+ log.info(`stopping: ${reason}`)
116
+ try {
117
+ await server.stop()
118
+ } finally {
119
+ process.exit(0)
120
+ }
121
+ }
122
+
123
+ process.on('SIGTERM', () => void shutdown('SIGTERM'))
124
+ process.on('SIGINT', () => void shutdown('SIGINT'))
125
+
126
+ // With a token on an fd, a parent owns this process: its stdin closing is
127
+ // the parent going away, and a crash that closes nothing still shows up
128
+ // as a changed parent pid. Started by hand, stdin is often /dev/null and
129
+ // would "close" at once, so only the signals apply.
130
+ if (tokenFd !== undefined) {
131
+ process.stdin.on('end', () => void shutdown('stdin closed'))
132
+ process.stdin.on('error', () => void shutdown('stdin errored'))
133
+ process.stdin.resume()
134
+ const parent = process.ppid
135
+ setInterval(() => {
136
+ if (process.ppid !== parent) void shutdown('parent exited')
137
+ }, PARENT_CHECK_MS)
138
+ }
139
+
140
+ // The daemon ends by exiting, never by returning: a return would print a
141
+ // second line to stdout, and the contract is exactly one.
142
+ return new Promise<CommandResult>(() => {})
143
+ },
144
+ }
@@ -41,6 +41,7 @@ import { setupCommand } from './setup'
41
41
  import { authCommands } from './auth/index-commands'
42
42
  import { kitCommands } from './kit/index-commands'
43
43
  import { workspaceCommands } from './workspace/index-commands'
44
+ import { forgeCommands } from './forge/index-commands'
44
45
 
45
46
  /**
46
47
  * Flags every command accepts. Declared once so a caller never has to wonder
@@ -74,6 +75,7 @@ export const COMMANDS: readonly Command[] = [
74
75
  ...authCommands,
75
76
  ...kitCommands,
76
77
  ...workspaceCommands,
78
+ ...forgeCommands,
77
79
 
78
80
  appInit,
79
81
  appList,
@@ -149,6 +151,7 @@ const NOUN_SUMMARY: Readonly<Record<string, string>> = {
149
151
  auth: 'Credential profiles — one per API key, selected by directory',
150
152
  kit: 'Optional project-local Frontera skills for Codex and Claude Code',
151
153
  workspace: 'Workspaces here — the ids --workspace takes',
154
+ forge: 'Forge — the local daemon the desktop app talks to',
152
155
  }
153
156
 
154
157
  export function nounSummary(noun: string): string {
package/src/flag-help.ts CHANGED
@@ -54,6 +54,10 @@ export const FLAG_HELP: Readonly<Record<string, string>> = {
54
54
  output: 'project-relative .ts path for generated output',
55
55
  check: 'verify generated output is current without writing it',
56
56
 
57
+ // Forge daemon
58
+ origin: 'browser origin allowed by CORS — the platform web origin; also FRONTERA_FORGE_ORIGIN',
59
+ 'token-fd': 'file descriptor to read the bearer token from; omitted, a token is generated and printed',
60
+
57
61
  // Project resolution
58
62
  dir: 'project directory to act on (default: walk up from the working directory)',
59
63
 
@@ -66,7 +70,7 @@ export const FLAG_HELP: Readonly<Record<string, string>> = {
66
70
 
67
71
  // Apps
68
72
  framework: 'App scaffold to create: next (default) or react (legacy Vite)',
69
- port: 'loopback port for the local App development server',
73
+ port: 'loopback port to listen on — the App development server, or the Forge daemon (0: any free port)',
70
74
  'no-open': 'start local development without opening a browser window',
71
75
  'no-install': 'scaffold without running `bun install` — for an offline machine or a sandbox',
72
76
  'no-git': 'scaffold without creating a repository and an initial commit',
@@ -171,6 +175,8 @@ const PLACEHOLDER: Readonly<Record<string, string>> = {
171
175
  version: 'semver',
172
176
  framework: 'next|react',
173
177
  port: 'number',
178
+ origin: 'url',
179
+ 'token-fd': 'fd',
174
180
  'expect-revision': 'hash',
175
181
  notes: 'text',
176
182
  description: 'text',
@@ -0,0 +1,108 @@
1
+ import type { ForgeHostId, ForgeProject, ForgeThread, ForgeThreadStatus } from '@frontera-sdk/forge-contracts'
2
+ import type { UIMessageChunk } from 'ai'
3
+
4
+ /**
5
+ * The seam between the daemon and a coding host.
6
+ *
7
+ * T3's `ProviderAdapter` reduced to what the Forge surface uses: one turn in,
8
+ * a stream of AI SDK chunks out, an interrupt, and an answer to a permission
9
+ * prompt. Everything host-specific — the Agent SDK for Claude Code, the app
10
+ * server for Codex — stays behind this line, so the renderer never sees a
11
+ * native event and the run manager never learns a host's vocabulary.
12
+ */
13
+
14
+ export type Emit = (chunk: UIMessageChunk) => void
15
+
16
+ export type ApprovalDecision = 'approve' | 'deny'
17
+
18
+ export interface TurnContext {
19
+ runId: string
20
+ threadId: string
21
+ /** Id of the assistant message this turn produces; the `start` chunk carries it. */
22
+ messageId: string
23
+ /** The user's text for this turn. */
24
+ text: string
25
+ project: ForgeProject
26
+ thread: ForgeThread
27
+ /** Platform API origin the daemon was started against; '' when unknown. */
28
+ apiUrl: string
29
+ /** Fires on cancel. */
30
+ signal: AbortSignal
31
+ /** The host's own session/thread id, once known — persisted for resume. */
32
+ setHostSessionId(id: string): void
33
+ setThreadStatus(status: ForgeThreadStatus): void
34
+ }
35
+
36
+ export interface TurnOutcome {
37
+ /** Null when the turn ended cleanly (including a user interrupt). */
38
+ error: string | null
39
+ }
40
+
41
+ export interface HostAdapter {
42
+ /** Resolves when the host's turn is over. Every chunk goes through `emit`. */
43
+ startTurn(ctx: TurnContext, emit: Emit): Promise<TurnOutcome>
44
+ interrupt(): Promise<void>
45
+ /** Answer a pending approval; false when the id is unknown to this adapter. */
46
+ respondApproval(approvalId: string, decision: ApprovalDecision): boolean
47
+ }
48
+
49
+ export type AdapterFactory = (host: ForgeHostId) => HostAdapter
50
+
51
+ /** How long a permission prompt waits before it is treated as denied. */
52
+ export const APPROVAL_TIMEOUT_MS = 10 * 60 * 1000
53
+
54
+ interface PendingApproval {
55
+ resolve(decision: ApprovalDecision): void
56
+ timer: ReturnType<typeof setTimeout>
57
+ }
58
+
59
+ /**
60
+ * Parks a host's permission prompt until the person answers on the approvals
61
+ * route. Shared by adapters because the chunk it emits and the thread status
62
+ * it toggles are the same for every host — only the question differs.
63
+ */
64
+ export class ApprovalBroker {
65
+ private readonly pending = new Map<string, PendingApproval>()
66
+
67
+ request(
68
+ ctx: Pick<TurnContext, 'setThreadStatus' | 'signal'>,
69
+ emit: Emit,
70
+ toolCallId: string,
71
+ timeoutMs = APPROVAL_TIMEOUT_MS,
72
+ ): Promise<ApprovalDecision> {
73
+ const approvalId = crypto.randomUUID()
74
+ return new Promise<ApprovalDecision>((resolve) => {
75
+ const settle = (decision: ApprovalDecision) => {
76
+ const entry = this.pending.get(approvalId)
77
+ if (!entry) return
78
+ clearTimeout(entry.timer)
79
+ this.pending.delete(approvalId)
80
+ ctx.signal.removeEventListener('abort', onAbort)
81
+ if (this.pending.size === 0) ctx.setThreadStatus('running')
82
+ resolve(decision)
83
+ }
84
+ const onAbort = () => settle('deny')
85
+ const timer = setTimeout(() => settle('deny'), timeoutMs)
86
+ this.pending.set(approvalId, { resolve: settle, timer })
87
+ ctx.signal.addEventListener('abort', onAbort, { once: true })
88
+ ctx.setThreadStatus('awaiting_approval')
89
+ emit({ type: 'tool-approval-request', approvalId, toolCallId } as UIMessageChunk)
90
+ if (ctx.signal.aborted) settle('deny')
91
+ })
92
+ }
93
+
94
+ respond(approvalId: string, decision: ApprovalDecision): boolean {
95
+ const entry = this.pending.get(approvalId)
96
+ if (!entry) return false
97
+ entry.resolve(decision)
98
+ return true
99
+ }
100
+
101
+ denyAll(): void {
102
+ for (const id of [...this.pending.keys()]) this.respond(id, 'deny')
103
+ }
104
+
105
+ get size(): number {
106
+ return this.pending.size
107
+ }
108
+ }
@@ -0,0 +1,205 @@
1
+ import type { CanUseTool, Options, PermissionMode, Query } from '@anthropic-ai/claude-agent-sdk'
2
+
3
+ import { resolveCredential } from '../config'
4
+ import { ApprovalBroker, type ApprovalDecision, type Emit, type HostAdapter, type TurnContext, type TurnOutcome } from './adapter'
5
+ import { ClaudeEventTranslator } from './claude-translator'
6
+ import { forgeEnv, forgePath } from './env'
7
+ import type { Logger } from './log'
8
+
9
+ /**
10
+ * Claude Code as a Forge host, driven through `@anthropic-ai/claude-agent-sdk`.
11
+ *
12
+ * The SDK spawns the developer's own `claude` binary, so their login, settings
13
+ * and plugins (the Frontera plugin `frontera setup` installed) all apply. Two
14
+ * things make the spawned host a Frontera agent on top of that: the platform's
15
+ * MCP server injected with the profile's key, and `frontera` first on PATH.
16
+ *
17
+ * HOME is never overridden. T3 learnt that relocating HOME moves the macOS
18
+ * keychain lookup with it, and the spawned CLI then reports "not logged in"
19
+ * to a person who is. CLAUDE_CONFIG_DIR is inherited if the environment sets
20
+ * it, and otherwise left alone.
21
+ */
22
+
23
+ const DENIED_MESSAGE = 'Denied by the user in Forge'
24
+
25
+ /** The host's own file-editing tools; `acceptEdits` is named after these. */
26
+ const EDIT_TOOLS = new Set(['Edit', 'Write', 'NotebookEdit', 'MultiEdit'])
27
+
28
+ /** Effort levels the Agent SDK accepts. Anything else is dropped, not guessed. */
29
+ const EFFORT_LEVELS = new Set(['low', 'medium', 'high', 'xhigh', 'max'])
30
+
31
+ /**
32
+ * Which prompts the mode answers on its own. The host already skips the
33
+ * prompts its `permissionMode` covers; this decides the ones that still reach
34
+ * us. Shell and platform tools stay behind a prompt under `acceptEdits`
35
+ * because both can write somewhere a file edit cannot, which is exactly what
36
+ * Claude Code itself means by "accept edits".
37
+ *
38
+ * `plan` is the SDK's own affair — it refuses the mutating tools upstream —
39
+ * so anything that arrives here under it is asked.
40
+ */
41
+ export function autoAllows(mode: string, toolName: string): boolean {
42
+ if (mode === 'bypassPermissions') return true
43
+ if (mode === 'acceptEdits') return EDIT_TOOLS.has(toolName)
44
+ // `auto` is the SDK's own model classifier deciding. Anything it hands to
45
+ // the callback is a call it did NOT approve, so approving it here would
46
+ // quietly turn Auto into Full access.
47
+ return false
48
+ }
49
+
50
+ /** The thread's effort, when the SDK would accept it. */
51
+ export function claudeEffort(effort: string | null): Options['effort'] | undefined {
52
+ if (!effort || !EFFORT_LEVELS.has(effort)) return undefined
53
+ return effort as NonNullable<Options['effort']>
54
+ }
55
+
56
+ /** A message that names the fix. The SDK's own errors rarely do. */
57
+ export function describeClaudeFailure(err: unknown): string {
58
+ const raw = err instanceof Error ? err.message : String(err)
59
+ const lower = raw.toLowerCase()
60
+ if (lower.includes('enoent') || lower.includes('not found') || lower.includes('no such file')) {
61
+ return `Claude Code could not be started (${raw}). Install it from https://claude.com/claude-code, then run \`claude auth login\`.`
62
+ }
63
+ if (lower.includes('not logged in') || lower.includes('unauthori') || lower.includes('authentication') || lower.includes('401')) {
64
+ return `Claude Code is not signed in (${raw}). Run \`claude auth login\` and try again.`
65
+ }
66
+ return `Claude Code turn failed: ${raw}. If Claude Code is not signed in, run \`claude auth login\`.`
67
+ }
68
+
69
+ export interface ClaudeAdapterDeps {
70
+ log: Logger
71
+ /** Injected so a test never spawns the real binary. */
72
+ query?: (params: { prompt: string; options: Options }) => Query
73
+ which?: (bin: string) => string | null
74
+ }
75
+
76
+ export class ClaudeAdapter implements HostAdapter {
77
+ private readonly approvals = new ApprovalBroker()
78
+ private current: Query | null = null
79
+
80
+ constructor(private readonly deps: ClaudeAdapterDeps) {}
81
+
82
+ async startTurn(ctx: TurnContext, emit: Emit): Promise<TurnOutcome> {
83
+ const translator = new ClaudeEventTranslator(ctx.messageId)
84
+ const flush = (chunks: Iterable<import('ai').UIMessageChunk>) => {
85
+ for (const chunk of chunks) emit(chunk)
86
+ }
87
+
88
+ const which = this.deps.which ?? ((bin: string) => Bun.which(bin, { PATH: forgePath() }))
89
+ const claudePath = which('claude')
90
+ if (!claudePath) {
91
+ flush(translator.fail('Claude Code is not installed on this machine. Install it from https://claude.com/claude-code, then run `claude auth login`.'))
92
+ return { error: translator.errorText }
93
+ }
94
+
95
+ const mode = ctx.thread.mode
96
+ const canUseTool: CanUseTool = async (toolName, input, options) => {
97
+ if (autoAllows(mode, toolName)) return { behavior: 'allow', updatedInput: input }
98
+ const toolCallId = options.toolUseID ?? crypto.randomUUID()
99
+ flush(translator.ensureToolCall(toolCallId, toolName, input))
100
+ const decision = await this.approvals.request(ctx, emit, toolCallId)
101
+ if (decision === 'approve') return { behavior: 'allow', updatedInput: input }
102
+ return { behavior: 'deny', message: DENIED_MESSAGE }
103
+ }
104
+
105
+ const effort = claudeEffort(ctx.thread.effort)
106
+ const options: Options = {
107
+ cwd: ctx.project.root,
108
+ ...(ctx.thread.model ? { model: ctx.thread.model } : {}),
109
+ pathToClaudeCodeExecutable: claudePath,
110
+ systemPrompt: { type: 'preset', preset: 'claude_code' },
111
+ // The thread's mode IS the SDK's `permissionMode` — including `plan`,
112
+ // which passes straight through for the host to enforce.
113
+ permissionMode: mode as PermissionMode,
114
+ ...(effort ? { effort } : {}),
115
+ ...(mode === 'bypassPermissions' ? { allowDangerouslySkipPermissions: true } : {}),
116
+ ...(ctx.thread.hostSessionId ? { resume: ctx.thread.hostSessionId } : {}),
117
+ includePartialMessages: true,
118
+ settingSources: ['user', 'project', 'local'],
119
+ additionalDirectories: [ctx.project.root],
120
+ // Under bypassPermissions the SDK never consults the callback and warns
121
+ // loudly that it was given one.
122
+ ...(mode === 'bypassPermissions' ? {} : { canUseTool }),
123
+ env: forgeEnv(),
124
+ ...(await this.mcpServers(ctx)),
125
+ }
126
+
127
+ const query = this.deps.query ?? (await import('@anthropic-ai/claude-agent-sdk')).query
128
+ let q: Query
129
+ try {
130
+ q = query({ prompt: ctx.text, options })
131
+ } catch (err) {
132
+ flush(translator.fail(describeClaudeFailure(err)))
133
+ return { error: translator.errorText }
134
+ }
135
+ this.current = q
136
+
137
+ try {
138
+ for await (const message of q) {
139
+ if (ctx.signal.aborted && translator.isFinished) break
140
+ const chunks = translator.translate(message)
141
+ if (translator.sessionId && translator.sessionId !== ctx.thread.hostSessionId) {
142
+ ctx.setHostSessionId(translator.sessionId)
143
+ }
144
+ flush(chunks)
145
+ if (translator.isFinished) break
146
+ }
147
+ flush(translator.finish())
148
+ } catch (err) {
149
+ if (ctx.signal.aborted) {
150
+ flush(translator.finish())
151
+ } else {
152
+ this.deps.log.error(`claude turn ${ctx.runId} failed: ${err instanceof Error ? err.message : String(err)}`)
153
+ flush(translator.fail(describeClaudeFailure(err)))
154
+ }
155
+ } finally {
156
+ this.current = null
157
+ this.approvals.denyAll()
158
+ }
159
+
160
+ return { error: ctx.signal.aborted ? null : translator.errorText }
161
+ }
162
+
163
+ async interrupt(): Promise<void> {
164
+ this.approvals.denyAll()
165
+ const q = this.current
166
+ if (!q) return
167
+ try {
168
+ await q.interrupt()
169
+ } catch (err) {
170
+ this.deps.log.warn(`claude interrupt failed: ${err instanceof Error ? err.message : String(err)}`)
171
+ }
172
+ }
173
+
174
+ respondApproval(approvalId: string, decision: ApprovalDecision): boolean {
175
+ return this.approvals.respond(approvalId, decision)
176
+ }
177
+
178
+ /**
179
+ * The platform's MCP server, with the key the project directory selects.
180
+ * No profile bound, or a profile for another deployment: the host still
181
+ * runs, just without platform tools — and the log says why.
182
+ */
183
+ private async mcpServers(ctx: TurnContext): Promise<Pick<Options, 'mcpServers'>> {
184
+ try {
185
+ const credential = await resolveCredential(
186
+ ctx.apiUrl ? { apiUrl: ctx.apiUrl } : {},
187
+ { cwd: ctx.project.root },
188
+ )
189
+ return {
190
+ mcpServers: {
191
+ frontera: {
192
+ type: 'http',
193
+ url: `${credential.apiUrl.replace(/\/+$/, '')}/v1/mcp/copilot`,
194
+ headers: { Authorization: `Bearer ${credential.token}` },
195
+ },
196
+ },
197
+ }
198
+ } catch (err) {
199
+ this.deps.log.warn(
200
+ `no platform MCP for ${ctx.project.root}: ${err instanceof Error ? err.message : String(err)}`,
201
+ )
202
+ return {}
203
+ }
204
+ }
205
+ }
@@ -0,0 +1,136 @@
1
+ /**
2
+ * The slash commands Claude Code advertises for a session.
3
+ *
4
+ * Follows T3 Code's `ClaudeProvider.ts`: start a `query()` whose prompt
5
+ * generator never yields, read `initializationResult()`, take the commands it
6
+ * advertises, then abort. Nothing reaches the model — the handshake is the
7
+ * whole point of the call.
8
+ *
9
+ * The seeded `compact` entry is theirs too, description included: it makes the
10
+ * list useful before any probe has returned, and on a machine where the probe
11
+ * fails there is still the one command everybody reaches for.
12
+ *
13
+ * `terminal_slash_commands` is filtered out on the SDK's own advice — it tags
14
+ * the commands whose behaviour is bound to the local terminal (`/exit`, the
15
+ * statusline) and says a non-terminal UI should hide them. Forge is one.
16
+ */
17
+ import type { ForgeSlashCommand } from '@frontera-sdk/forge-contracts'
18
+
19
+ import { forgeEnv } from './env'
20
+ import type { Logger } from './log'
21
+
22
+ const PROBE_TIMEOUT_MS = 15_000
23
+ const CACHE_MS = 60_000
24
+
25
+ /** T3 Code seeds this one so the menu is never empty. Same wording. */
26
+ const SEEDED: ForgeSlashCommand[] = [
27
+ { name: 'compact', description: 'Summarize the conversation and reduce context usage' },
28
+ ]
29
+
30
+ interface Probed {
31
+ at: number
32
+ commands: ForgeSlashCommand[]
33
+ }
34
+
35
+ const cache = new Map<string, Probed>()
36
+ const inFlight = new Map<string, Promise<ForgeSlashCommand[]>>()
37
+
38
+ function dedupe(commands: ForgeSlashCommand[]): ForgeSlashCommand[] {
39
+ const byName = new Map<string, ForgeSlashCommand>()
40
+ for (const command of commands) {
41
+ const name = command.name.trim().replace(/^\//, '')
42
+ if (!name || byName.has(name)) continue
43
+ byName.set(name, { ...command, name })
44
+ }
45
+ return [...byName.values()]
46
+ }
47
+
48
+ /**
49
+ * One handshake against the host, cancelled the moment it answers.
50
+ *
51
+ * Failure is not exceptional — the binary may be missing or logged out — and
52
+ * the caller still gets the seeded list, so a menu never breaks on it.
53
+ */
54
+ async function probe(cwd: string, claudePath: string, log?: Logger): Promise<ForgeSlashCommand[]> {
55
+ const abort = new AbortController()
56
+ const timer = setTimeout(() => abort.abort(), PROBE_TIMEOUT_MS)
57
+ try {
58
+ const { query } = await import('@anthropic-ai/claude-agent-sdk')
59
+ const session = query({
60
+ // Never yields: the initialization response is all this call is for, and
61
+ // a prompt that resolved would send a turn to the model.
62
+ prompt: (async function* () {
63
+ await new Promise<void>((resolve) => {
64
+ if (abort.signal.aborted) resolve()
65
+ else abort.signal.addEventListener('abort', () => resolve(), { once: true })
66
+ })
67
+ })(),
68
+ options: {
69
+ cwd,
70
+ pathToClaudeCodeExecutable: claudePath,
71
+ abortController: abort,
72
+ env: forgeEnv(),
73
+ },
74
+ })
75
+
76
+ const init = (await session.initializationResult()) as {
77
+ commands?: Array<{ name?: string; description?: string; argumentHint?: string }>
78
+ terminal_slash_commands?: string[]
79
+ }
80
+ const terminalOnly = new Set(init.terminal_slash_commands ?? [])
81
+ const advertised = (init.commands ?? []).flatMap((command) => {
82
+ const name = command.name?.trim()
83
+ if (!name || terminalOnly.has(name)) return []
84
+ const description = command.description?.trim()
85
+ const hint = command.argumentHint?.trim()
86
+ return [
87
+ {
88
+ name,
89
+ ...(description ? { description } : {}),
90
+ ...(hint ? { argumentHint: hint } : {}),
91
+ } satisfies ForgeSlashCommand,
92
+ ]
93
+ })
94
+ return dedupe([...SEEDED, ...advertised])
95
+ } catch (error) {
96
+ log?.warn(`slash-command probe failed: ${error instanceof Error ? error.message : String(error)}`)
97
+ return dedupe(SEEDED)
98
+ } finally {
99
+ clearTimeout(timer)
100
+ if (!abort.signal.aborted) abort.abort()
101
+ }
102
+ }
103
+
104
+ /**
105
+ * What the composer lists, answered immediately.
106
+ *
107
+ * The seeded commands come back on the first call while the probe runs behind
108
+ * it; the richer list replaces them once the host has answered. A menu that
109
+ * waits on a subprocess is a menu that feels broken.
110
+ */
111
+ export function commandsFor(cwd: string, claudePath: string | null, log?: Logger): ForgeSlashCommand[] {
112
+ if (!claudePath) return dedupe(SEEDED)
113
+
114
+ const hit = cache.get(cwd)
115
+ if (hit && Date.now() - hit.at < CACHE_MS) return hit.commands
116
+
117
+ if (!inFlight.has(cwd)) {
118
+ const pending = probe(cwd, claudePath, log)
119
+ .then((commands) => {
120
+ cache.set(cwd, { at: Date.now(), commands })
121
+ return commands
122
+ })
123
+ .finally(() => inFlight.delete(cwd))
124
+ inFlight.set(cwd, pending)
125
+ }
126
+
127
+ return hit?.commands ?? dedupe(SEEDED)
128
+ }
129
+
130
+ /** Exported for tests. */
131
+ export function clearCommandCache(): void {
132
+ cache.clear()
133
+ inFlight.clear()
134
+ }
135
+
136
+ export { SEEDED as SEEDED_COMMANDS }