@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 +6 -3
- package/src/commands/forge/index-commands.ts +4 -0
- package/src/commands/forge/serve.ts +144 -0
- package/src/commands/registry.ts +3 -0
- package/src/flag-help.ts +7 -1
- package/src/forge/adapter.ts +108 -0
- package/src/forge/claude-adapter.ts +205 -0
- package/src/forge/claude-commands.ts +136 -0
- package/src/forge/claude-skills.ts +189 -0
- package/src/forge/claude-translator.ts +361 -0
- package/src/forge/codex-adapter.ts +28 -0
- package/src/forge/env.ts +42 -0
- package/src/forge/hosts.ts +323 -0
- package/src/forge/http.ts +74 -0
- package/src/forge/log.ts +53 -0
- package/src/forge/projects.ts +349 -0
- package/src/forge/protocol.ts +38 -0
- package/src/forge/runs.ts +250 -0
- package/src/forge/server.ts +655 -0
- package/src/forge/sessions.ts +49 -0
- package/src/forge/store.ts +427 -0
- package/src/forge/terminals.ts +220 -0
- package/src/forge/turn-text.ts +35 -0
- package/src/kit.ts +25 -7
- package/src/vendor/sdk-sources.json +17 -17
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@frontera-sdk/cli",
|
|
3
|
-
"version": "1.
|
|
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
|
-
"@
|
|
42
|
-
"@frontera-sdk/
|
|
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,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
|
+
}
|
package/src/commands/registry.ts
CHANGED
|
@@ -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
|
|
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 }
|