mirror of
https://github.com/stablyai/orca.git
synced 2026-11-14 01:47:24 +00:00
Jcode accepts a string or an array of strings per hook event, but Orca's [hooks] parser only understood single-line strings, so any array made status and install report error and no hook was written. Parse string arrays, append Orca's command as the last element (after the user's pre_tool gates), repair a stale Orca element in place, collapse duplicate managed elements on reinstall, and remove only Orca's element on uninstall. Edits are offset splices on the original text, so user entries, comments and layout survive; malformed or non-string arrays still fail closed. Fixes #33998 Co-authored-by: Neil <neil@stably.ai>
226 lines
7.9 KiB
TypeScript
226 lines
7.9 KiB
TypeScript
// Reuse byte-preserving TOML scanning; scalar hook commands stay user-owned unless they target Orca.
|
|
import { applyEdits, type Edit } from 'jsonc-parser'
|
|
import {
|
|
createTomlLineScanState,
|
|
getTomlTableHeader,
|
|
isTomlStructuralLine,
|
|
updateTomlLineScanState,
|
|
parseTomlSingleLineStringValue
|
|
} from '../codex/config-toml-line-scan'
|
|
import { parseTomlKeyPath, parseTomlTableHeaderPath } from '../codex/config-toml-key-path'
|
|
import { createManagedCommandMatcher } from '../agent-hooks/installer-utils'
|
|
import {
|
|
appendJcodeHookCommand,
|
|
removeJcodeHookCommand,
|
|
scanJcodeHookCommandArray
|
|
} from './hook-command-array'
|
|
|
|
export type JcodeHooksTable = Record<string, string | readonly string[]>
|
|
|
|
type JcodeHookAssignment = {
|
|
key: string
|
|
dotted: boolean
|
|
lineStart: number
|
|
/** Excludes the line terminator. */
|
|
lineEnd: number
|
|
valueStart: number
|
|
}
|
|
|
|
function scanJcodeHookAssignments(content: string): {
|
|
assignments: JcodeHookAssignment[]
|
|
/** End of the first [hooks] header line, or -1 when there is none. */
|
|
hooksHeaderEnd: number
|
|
} {
|
|
const assignments: JcodeHookAssignment[] = []
|
|
let hooksHeaderEnd = -1
|
|
let state = createTomlLineScanState()
|
|
let inHooksTable = false
|
|
let lineStart = 0
|
|
for (const rawLine of content.split('\n')) {
|
|
const line = rawLine.replace(/\r$/, '')
|
|
if (isTomlStructuralLine(state)) {
|
|
const header = getTomlTableHeader(line)
|
|
if (header) {
|
|
inHooksTable = isHooksTableHeader(header)
|
|
if (inHooksTable && hooksHeaderEnd === -1) {
|
|
hooksHeaderEnd = lineStart + line.length
|
|
}
|
|
} else if (inHooksTable) {
|
|
const parsed = parseTomlKeyPath(line)
|
|
if (parsed && line[parsed.end] === '=') {
|
|
assignments.push({
|
|
key: parsed.segments[0],
|
|
dotted: parsed.segments.length > 1,
|
|
lineStart,
|
|
lineEnd: lineStart + line.length,
|
|
valueStart: lineStart + parsed.end + 1
|
|
})
|
|
}
|
|
}
|
|
}
|
|
state = updateTomlLineScanState(state, line)
|
|
lineStart += rawLine.length + 1
|
|
}
|
|
return { assignments, hooksHeaderEnd }
|
|
}
|
|
|
|
export function parseJcodeHooksTable(content: string): JcodeHooksTable | null {
|
|
const hooks: JcodeHooksTable = {}
|
|
for (const assignment of scanJcodeHookAssignments(content).assignments) {
|
|
const value = parseTomlSingleLineStringValue(content, assignment.valueStart)
|
|
const array = value ? null : scanJcodeHookCommandArray(content, assignment.valueStart)
|
|
if (value) {
|
|
hooks[assignment.key] = value.value
|
|
} else if (array) {
|
|
hooks[assignment.key] = array.elements.map((element) => element.value)
|
|
} else if (
|
|
!assignment.dotted &&
|
|
!isTomlScalarValue(content.slice(assignment.valueStart, assignment.lineEnd))
|
|
) {
|
|
// Fail closed on multiline strings and malformed arrays; numeric settings such as pre_tool_timeout_ms are valid.
|
|
return null
|
|
}
|
|
}
|
|
return hooks
|
|
}
|
|
|
|
// jcode seeds non-command scalars; inline comments must not make them parse errors.
|
|
const TOML_SCALAR_VALUE_RE =
|
|
/^\s*(?:true|false|[-+]?\d[\d_]*(?:\.[\d_]+)?(?:[eE][-+]?\d+)?)\s*(?:#.*)?$/
|
|
|
|
function isTomlScalarValue(value: string): boolean {
|
|
return TOML_SCALAR_VALUE_RE.test(value)
|
|
}
|
|
|
|
// Reuse quoted/dotted header parsing to avoid appending a duplicate hooks table.
|
|
function isHooksTableHeader(header: string): boolean {
|
|
return parseTomlTableHeaderPath(header)?.segments.join('.') === 'hooks'
|
|
}
|
|
|
|
export function tomlQuoteString(value: string): string {
|
|
return `"${value.replaceAll('\\', '\\\\').replaceAll('"', '\\"')}"`
|
|
}
|
|
|
|
export type JcodeHooksEdit = {
|
|
content: string
|
|
/** Managed keys whose value is a user-owned command (kept, reported partial). */
|
|
userOwnedEvents: readonly string[]
|
|
}
|
|
|
|
export function applyJcodeManagedHooks(
|
|
source: string,
|
|
events: readonly string[],
|
|
managedCommand: string,
|
|
scriptFileName: string
|
|
): JcodeHooksEdit {
|
|
const isManaged = createManagedCommandMatcher(scriptFileName)
|
|
// Keep one Orca element per array so the repair below cannot leave stale duplicates running.
|
|
const content = removeManagedHookCommands(source, isManaged, true)
|
|
const managedValue = tomlQuoteString(managedCommand)
|
|
const eol = content.includes('\r\n') ? '\r\n' : '\n'
|
|
const { assignments, hooksHeaderEnd } = scanJcodeHookAssignments(content)
|
|
const edits: Edit[] = []
|
|
const userOwnedEvents: string[] = []
|
|
const missing: string[] = []
|
|
for (const event of events) {
|
|
const assignment = assignments.find((entry) => entry.key === event && !entry.dotted)
|
|
if (!assignment) {
|
|
missing.push(event)
|
|
continue
|
|
}
|
|
const array = scanJcodeHookCommandArray(content, assignment.valueStart)
|
|
if (array) {
|
|
// Append after user gates: jcode runs every pre_tool command in order, so Orca observes the final input.
|
|
if (!array.elements.some((element) => element.value === managedCommand)) {
|
|
const stale = array.elements.find((element) => isManaged(element.value))
|
|
edits.push(
|
|
...(stale
|
|
? [{ offset: stale.start, length: stale.end - stale.start, content: managedValue }]
|
|
: appendJcodeHookCommand(content, array, managedValue))
|
|
)
|
|
}
|
|
continue
|
|
}
|
|
// Parse the string so trailing comments cannot confer ownership and escaped Windows paths still match.
|
|
const value = parseTomlSingleLineStringValue(content, assignment.valueStart)?.value
|
|
if (!isManaged(value)) {
|
|
userOwnedEvents.push(event)
|
|
} else if (value !== managedCommand) {
|
|
// Repair stale managed paths after copied configs or platform switches.
|
|
edits.push({
|
|
offset: assignment.lineStart,
|
|
length: assignment.lineEnd - assignment.lineStart,
|
|
content: `${event} = ${managedValue}`
|
|
})
|
|
}
|
|
}
|
|
if (missing.length > 0) {
|
|
const lines = missing.map((event) => `${event} = ${managedValue}`).join(eol)
|
|
edits.push(
|
|
hooksHeaderEnd === -1
|
|
? {
|
|
offset: content.length,
|
|
length: 0,
|
|
content: `${content.endsWith('\n') || content.length === 0 ? '' : '\n'}[hooks]${eol}${lines}${eol}`
|
|
}
|
|
: { offset: hooksHeaderEnd, length: 0, content: `${eol}${lines}` }
|
|
)
|
|
}
|
|
return { content: applyEdits(content, edits), userOwnedEvents }
|
|
}
|
|
|
|
export function removeJcodeManagedHooks(
|
|
content: string,
|
|
scriptFileName: string
|
|
): { content: string; changed: boolean } {
|
|
const result = removeManagedHookCommands(
|
|
content,
|
|
createManagedCommandMatcher(scriptFileName),
|
|
false
|
|
)
|
|
return { content: result, changed: result !== content }
|
|
}
|
|
|
|
function removeManagedHookCommands(
|
|
content: string,
|
|
isManaged: (command: string | undefined) => boolean,
|
|
keepFirst: boolean
|
|
): string {
|
|
let result = content
|
|
// Each pass drops at most one element per array so splices never overlap.
|
|
for (let edits = getRemovalEdits(result, isManaged, keepFirst); edits.length > 0;) {
|
|
result = applyEdits(result, edits)
|
|
edits = getRemovalEdits(result, isManaged, keepFirst)
|
|
}
|
|
return result
|
|
}
|
|
|
|
function getRemovalEdits(
|
|
content: string,
|
|
isManaged: (command: string | undefined) => boolean,
|
|
keepFirst: boolean
|
|
): Edit[] {
|
|
const edits: Edit[] = []
|
|
// Scan every [hooks] line so removal cannot truncate later user tables.
|
|
for (const assignment of scanJcodeHookAssignments(content).assignments) {
|
|
if (assignment.dotted) {
|
|
continue
|
|
}
|
|
const array = scanJcodeHookCommandArray(content, assignment.valueStart)
|
|
const managed = array?.elements.flatMap((element, i) => (isManaged(element.value) ? [i] : []))
|
|
const index = managed?.[keepFirst ? 1 : 0]
|
|
if (array && index !== undefined) {
|
|
edits.push(...removeJcodeHookCommand(content, array, index))
|
|
} else if (
|
|
!array &&
|
|
!keepFirst &&
|
|
isManaged(parseTomlSingleLineStringValue(content, assignment.valueStart)?.value)
|
|
) {
|
|
const nextLine = content.indexOf('\n', assignment.lineEnd)
|
|
const end = nextLine === -1 ? content.length : nextLine + 1
|
|
edits.push({ offset: assignment.lineStart, length: end - assignment.lineStart, content: '' })
|
|
}
|
|
}
|
|
return edits
|
|
}
|