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